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
package/CHANGELOG.md CHANGED
@@ -16,6 +16,247 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.9.0] — 2026-05-27
20
+
21
+ ### Added — layered frame-access helpers
22
+
23
+ Three new primitives completing the Tier 2 surface in the
24
+ three-tier extensibility pattern. See `docs/frame-access-tiers.md`
25
+ for the full decision flow + use-case mapping.
26
+
27
+ #### Layer 1 — `save_frame_as_jpeg` vc Frame Processor plugin (native)
28
+
29
+ Worklet-callable JPEG encoder. Registers on both platforms:
30
+
31
+ - **iOS** — `SaveFrameAsJpegPlugin.mm` (CIImage → CGImage → UIImage
32
+ → UIImageJPEGRepresentation → atomic NSData write). Registered
33
+ via `+ (void)load` hook into `FrameProcessorPluginRegistry`.
34
+ - **Android** — `SaveFrameAsJpegPlugin.kt` wrapping the lib's
35
+ existing `YuvImageConverter.encodeJpegFromNV21` encoder (the
36
+ same one used by `RNSARCameraView`'s keyframe-accept callback).
37
+ Registered alongside `cv_flow_gate_process_frame` in
38
+ `RNImageStitcherPackage.ensureFrameProcessorPluginRegistered`.
39
+
40
+ Plugin contract (identical on both platforms):
41
+ - Args: `path` (string, REQUIRED), `quality` (number 0-100,
42
+ default 75, clamped `[1, 100]`)
43
+ - Returns: `{ ok: true, path, width, height }` OR
44
+ `{ ok: false, error: "..." }`
45
+
46
+ Hosts can call this directly from their own `useFrameProcessor`
47
+ worklet for custom rate-control logic; most consumers use it
48
+ indirectly via Layer 3.
49
+
50
+ #### Layer 2 — `useThrottledFrameProcessor` hook
51
+
52
+ ```tsx
53
+ const fp = useThrottledFrameProcessor(
54
+ (frame) => {
55
+ 'worklet';
56
+ // Worklet-native processing at sub-frame-rate
57
+ },
58
+ { sampleHz: 2 },
59
+ [],
60
+ );
61
+ ```
62
+
63
+ Pure TS throttle gate over `useFrameProcessor` (v0.8.0). Worklet
64
+ fires up to `sampleHz` times per second; ticks too close together
65
+ dropped via a monotonic-time `useSharedValue` gate.
66
+
67
+ **Use for**: worklet-native processing — native OCR via
68
+ Vision.framework / ML Kit wrapped as vc Frame Processor plugins,
69
+ TFLite ML inference, LiDAR depth (`frame.arDepth`). Direct
70
+ buffer/pose/depth access in the worklet; bridge small bbox-result
71
+ payloads to JS via `runOnJS`.
72
+
73
+ `sampleHz` clamped to `[0.5, 30]`.
74
+
75
+ #### Layer 3 — `useFrameStream` hook
76
+
77
+ ```tsx
78
+ const fp = useFrameStream(
79
+ { sampleHz: 2, quality: 75 },
80
+ (sample) => {
81
+ // JS-thread callback: sample.jpegPath, sample.pose, sample.timestamp
82
+ setThumbnail(sample.jpegPath);
83
+ },
84
+ );
85
+ ```
86
+
87
+ Composes Layer 2 + Layer 1 + `runOnJS` bridge to deliver
88
+ `SampledFrame` objects to a JS-thread handler. Slot-reuse
89
+ strategy bounds disk usage to ~4 stale JPEGs.
90
+
91
+ **Use for**: JS-thread consumers — file-path OCR libraries (RN
92
+ modules wrapping ML Kit), cloud upload, thumbnail preview UI,
93
+ JS-side ML (TF.js, transformers.js).
94
+
95
+ `sampleHz` clamped to `[0.5, 10]`; `quality` clamped `[1, 100]`.
96
+
97
+ #### Types
98
+
99
+ - `SampledFrame` — `{ jpegPath, pose, timestamp, width, height }`
100
+ - `FrameStreamOptions` — `{ sampleHz, quality?, outputDir? }`
101
+ - `ThrottledFrameProcessorOptions` — `{ sampleHz }`
102
+
103
+ All exported from `react-native-image-stitcher`.
104
+
105
+ ### Documentation
106
+
107
+ - `docs/frame-access-tiers.md` — new comprehensive reference for
108
+ all four host-facing hooks (`useKeyframeStream`,
109
+ `useThrottledFrameProcessor`, `useFrameStream`,
110
+ `useFrameProcessor`) with decision flow, cost envelope, use-case
111
+ mapping, AR vs non-AR mode tradeoff.
112
+
113
+ ### Example app
114
+
115
+ `example/App.tsx` now mounts `useFrameStream` at 2 Hz with a
116
+ visible thumbnail overlay (bottom-right corner) — visual proof of
117
+ the Layer 1 + 2 + 3 pipeline working end-to-end on both iPhone
118
+ (60 Hz AR) and Galaxy A35 (30 Hz AR).
119
+
120
+ ### Compatibility
121
+
122
+ - Strict additive over v0.8.0. No host changes required.
123
+ - Works in both AR and non-AR modes via v0.8.0's unified
124
+ `useFrameProcessor`.
125
+ - New hooks return `useFrameProcessor`-shape objects compatible
126
+ with `<Camera frameProcessor={...}>` (Phase 5 from v0.8.0).
127
+
128
+ ### Notes
129
+
130
+ - Formal SSIM parity gate (Phase 7 of the v0.9.0 plan) was NOT
131
+ run for this release — the layered design doesn't touch
132
+ first-party stitching, so a regression is structurally unlikely.
133
+ Harness still in place from v0.8.0 (`scripts/ssim-compare.py`)
134
+ for any host that wants to run it locally.
135
+
136
+ [0.9.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.8.0...v0.9.0
137
+
138
+ ## [0.8.0] — 2026-05-27
139
+
140
+ ### Added — `useFrameProcessor` hook for host worklets
141
+
142
+ Hosts can now attach a `'worklet'`-prefixed function that fires on
143
+ every AR (and non-AR) capture frame, alongside the lib's own
144
+ first-party stitching. Use case: real-time OCR, packet detection,
145
+ ML inference, custom telemetry — anything that wants per-frame
146
+ pixel access in a worklet runtime.
147
+
148
+ ```tsx
149
+ import { useFrameProcessor, type StitcherFrame }
150
+ from 'react-native-image-stitcher';
151
+
152
+ const fp = useFrameProcessor((frame: StitcherFrame) => {
153
+ 'worklet';
154
+ // frame.toArrayBuffer(), frame.pose, frame.source ('ar' | 'vc'), …
155
+ }, []);
156
+ ```
157
+
158
+ **AR mode** (iPhone via ARKit, Android via ARCore): worklets fire
159
+ on every AR frame at the device's native rate (~30 Hz on A35,
160
+ ~60 Hz on iPhone 16 Pro). Auto-registered into a process-scope
161
+ native registry via `globalThis.__stitcherProxy.install(workletFn)`.
162
+ The AR-session dispatch path fans out to both the lib's first-party
163
+ stitching AND every registered host worklet, with **per-worklet
164
+ failure isolation** (one host worklet throwing does NOT break
165
+ others or the lib's stitching).
166
+
167
+ **Non-AR mode** (vision-camera): pass the hook's return through
168
+ `<Camera frameProcessor={fp}>` to enable. Honest tradeoff: vc's
169
+ `<Camera>` accepts ONE processor, so supplying a host processor
170
+ displaces the lib's first-party stitching in non-AR mode. Hosts
171
+ that want both running concurrently should use AR mode (which
172
+ natively composes both). Composition for non-AR is tracked as
173
+ v0.9+.
174
+
175
+ ### Added — `StitcherFrame` contract
176
+
177
+ Unified frame shape across AR and non-AR modes (`src/stitching/
178
+ StitcherFrame.ts`):
179
+
180
+ - `width` / `height` / `pixelFormat` / `orientation` / `timestamp`
181
+ / `toArrayBuffer()` — vc-shape parity
182
+ - `pose: { rotation: [x,y,z,w], translation?: [x,y,z] }` — always
183
+ present in AR mode; rotation-only in non-AR
184
+ - `source: 'ar' | 'vc'` discriminator for safe AR-field access
185
+ - `arDepth?`, `arAnchors?`, `arTrackingState?` — populated in AR
186
+ mode on supported devices
187
+
188
+ ### Added — JSI proxy host object
189
+
190
+ `globalThis.__stitcherProxy` installed on lib bootstrap (iOS:
191
+ `StitcherJsiInstaller` RN module via `RCTBridgeProxy.runtime` in
192
+ bridgeless mode; Android: `StitcherJsiInstallerModule` via
193
+ `ReactApplicationContext.getJavaScriptContextHolder()`). Exposes
194
+ `install` / `uninstall` / `count` host functions backed by a
195
+ shared C++ `retailens::StitcherWorkletRegistry` (process-scope,
196
+ mutex-serialised, snapshot-isolated).
197
+
198
+ ### Changed — AR-mode dispatch architecture
199
+
200
+ Internal-only refactor (strict additive BC for hosts that don't
201
+ use `useFrameProcessor`):
202
+
203
+ - **iOS**: `ARSessionDelegate.session(_:didUpdate:)` now routes
204
+ through `RNSARWorkletRuntime.dispatchFrame:pose:` instead of
205
+ directly invoking the engine. First-party callback (Phase 3c)
206
+ runs synchronously on the caller thread (preserves ARKit's
207
+ pool-reuse contract); host worklet fan-out (Phase 4b.i)
208
+ dispatches asynchronously onto a dedicated worklets-core
209
+ context.
210
+
211
+ - **Android**: `RNSARCameraView.onDrawFrame` now wraps the
212
+ existing `module.ingestFromARCameraView(...)` call in
213
+ `StitcherWorkletRuntime.runFirstParty { ... }` (Phase 3c) and
214
+ follows with `StitcherWorkletRuntime.dispatchToHostWorklets(...)`
215
+ (Phase 4b.iii). Per-frame fan-out runs every AR frame when host
216
+ worklets are registered (not just during capture).
217
+
218
+ ### Performance posture
219
+
220
+ - **First-party-only deployments** (no `useFrameProcessor`):
221
+ zero per-frame cost added. `hasHostWorklets()` atomic-read
222
+ short-circuits before any dispatch path.
223
+ - **Host worklets registered, idle preview**: Android pays
224
+ ~6-10ms per AR frame (NV21 pack + JNI byte copy + worklet
225
+ dispatch). iOS uses `CFBridgingRetain` (no per-frame copy,
226
+ but ARKit pool back-pressure on next frame). Both acceptable
227
+ for v0.8.0; future optimization → zero-copy NV21 transfer via
228
+ direct `ByteBuffer` (Android).
229
+
230
+ ### Added — SSIM parity gate harness
231
+
232
+ `scripts/ssim-compare.py` — pixel-wise SSIM comparison between
233
+ panorama JPEGs (Pillow + numpy + scikit-image; threshold 0.98).
234
+ Procedure in `docs/phase-7-parity-gate.md`.
235
+
236
+ > **v0.8.0 release note:** the formal SSIM parity gate was NOT
237
+ > run for this release. Verification rests on manual visual
238
+ > inspection of v0.8.0 panorama output on iPhone 16 Pro (Phase
239
+ > 4b.i) and Galaxy A35 (Phase 4b.iii) — both produced stitched
240
+ > panoramas matching the v0.7.x behaviour subjectively. The
241
+ > harness is in place for v0.8.1+ / future releases where the
242
+ > gate is mandatory.
243
+
244
+ ### Migration guide
245
+
246
+ No host-side changes required for the common case. Hosts that
247
+ want to attach worklets:
248
+
249
+ 1. Add `react-native-worklets-core` if not already a peer dep
250
+ (already in v0.7.x's peer-deps list).
251
+ 2. Replace `useFrameProcessor` imports from
252
+ `react-native-vision-camera` with the lib's own export:
253
+ ```diff
254
+ - import { useFrameProcessor } from 'react-native-vision-camera';
255
+ + import { useFrameProcessor } from 'react-native-image-stitcher';
256
+ ```
257
+ 3. Worklet body now receives `StitcherFrame` instead of vc's
258
+ `Frame` — see `src/stitching/StitcherFrame.ts` for the contract.
259
+
19
260
  ## [0.7.1] — 2026-05-26
20
261
 
21
262
  ### Fixed — CI binary-packaging bloat
@@ -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
+ }