react-native-image-stitcher 0.11.0 → 0.11.1

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 CHANGED
@@ -16,6 +16,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.11.1] — 2026-05-28
20
+
21
+ ### Fixed — AR-mode composed worklets silently throw
22
+
23
+ `useStitcherWorklet`'s `call(frame)` was invoking the vision-camera
24
+ Frame Processor plugin on every frame regardless of mode. In AR
25
+ mode the frame is a `StitcherFrameHostObject` (no `__frame` JSI
26
+ marker), so the vc plugin threw `getPropertyAsObject: property
27
+ '__frame' is undefined`. The throw was caught silently by
28
+ `RNSARWorkletRuntime`'s per-worklet error isolation (logged to
29
+ `os_log`, not surfaced to JS), causing any host code AFTER
30
+ `stitcher.call(frame)` in the composed worklet body —
31
+ `runOnJS` callbacks, `Worklets.createRunOnJS` dispatches, further
32
+ host worklet logic — to silently never execute in AR mode.
33
+
34
+ The hook's module docstring already promised AR mode would no-op
35
+ ("AR mode is unaffected — the AR-session dispatch path already
36
+ composes natively"), but the code didn't enforce it. v0.11.1 adds
37
+ an early-return on `frame.source === 'ar'` in `useStitcherWorklet`'s
38
+ worklet body. AR stitching continues to run natively via
39
+ `RNSARSession.swift`'s first-party callback path
40
+ (`consumer.consumeFrame(arFrame, pose)` at line 510-511), which is
41
+ the architectural contract for AR-mode stitching since v0.8.0.
42
+
43
+ This bug was latent in v0.11.0 — surfaced by Test 2 of
44
+ `docs/v0.11.0-manual-verification-checklist.md` on Ram's iPhone.
45
+
46
+ Also added: `StitcherJsiInstaller::install` now eagerly initializes
47
+ the worklets-core default `JsiWorkletContext` singleton during JSI
48
+ bootstrap. This is defense-in-depth — worklets-core's own `Worklets`
49
+ module also initializes the default, but eager init from our
50
+ installer makes `runOnJS` from AR-mode worklets robust to host-app
51
+ import order (no dependency on worklets-core's `Worklets` module
52
+ loading before our AR runtime constructs its context).
53
+
54
+ ### Added — Jest test for AR-source short-circuit
55
+
56
+ New test file `src/stitching/__tests__/useStitcherWorklet.test.ts`
57
+ pins the AR no-op contract. 5 new tests; full suite now 74/74 pass
58
+ (was 69/69 in v0.11.0).
59
+
19
60
  ## [0.11.0] — 2026-05-28
20
61
 
21
62
  ### Added — `useStitcherWorklet` for non-AR composition
@@ -223,6 +223,31 @@ function useStitcherWorklet(options = {}) {
223
223
  'worklet';
224
224
  if (plugin == null)
225
225
  return;
226
+ // v0.11.1 — AR-source frames are stitched natively by the AR-
227
+ // side dispatcher (`RNSARSession.swift:510-511` → the first-
228
+ // party callback installed in `RNSARWorkletRuntime`). Calling
229
+ // the vc Frame Processor plugin here would throw
230
+ // `getPropertyAsObject: property '__frame' is undefined`
231
+ // because AR frames are `StitcherFrameHostObject` instances
232
+ // and don't carry the vc `Frame` proxy's JSI marker. The
233
+ // throw is caught silently by the per-worklet error handler
234
+ // (`RNSARWorkletRuntime.mm:284-301`) and bubbles up only to
235
+ // `os_log` — invisible to JS, which is why pre-v0.11.1
236
+ // composed hosts saw their post-`stitcher.call` lines
237
+ // (`fireFrameProcessorLog`, `runOnJS` callbacks) silently
238
+ // never execute in AR mode. Silent no-op here matches the
239
+ // module-header promise that AR mode is "unaffected" by this
240
+ // hook (the AR-side stitching path runs natively, independent
241
+ // of the composed worklet body).
242
+ //
243
+ // The `(frame as StitcherFrame).source` cast is safe: vc
244
+ // `Frame` doesn't carry a `source` property so the check
245
+ // returns `undefined !== 'ar'` → `true`, and the worklet
246
+ // proceeds normally. Only frames that explicitly tag
247
+ // themselves as AR-source (which our native AR dispatcher
248
+ // does — see `StitcherFrameHostObject.mm`) get short-circuited.
249
+ if (frame.source === 'ar')
250
+ return;
226
251
  // Throttle (verbatim from useFrameProcessorDriver).
227
252
  sharedFrameCounter.value += 1;
228
253
  const N = sharedEvalEveryN.value;
@@ -22,11 +22,37 @@
22
22
  #import <React/RCTBridge.h>
23
23
  #import <React/RCTBridge+Private.h>
24
24
  #import <React/RCTUtils.h>
25
+ #import <ReactCommon/CallInvoker.h>
26
+ // `RCTCxxBridge` (and its bridgeless-mode `RCTBridgeProxy` forwarder)
27
+ // exposes `-jsCallInvoker` returning `std::shared_ptr<CallInvoker>`,
28
+ // but the property declaration lives in `<ReactCommon/RCTTurboModule.h>`
29
+ // which isn't on our pod's HEADER_SEARCH_PATHS (worklets-core gets it
30
+ // via its own ReactCommon dep). Rather than enlarging our pod's
31
+ // dependency surface, forward-declare the property in an anonymous
32
+ // category — the runtime dispatches to RN's actual implementation.
33
+ // Pattern matches `WKTJsiWorkletContext.cpp`'s approach to keep the
34
+ // pod self-contained.
35
+ @interface RCTCxxBridge ()
36
+ @property (nonatomic, readonly) std::shared_ptr<facebook::react::CallInvoker> jsCallInvoker;
37
+ @end
25
38
  #import <os/log.h>
26
39
 
27
40
  #include <jsi/jsi.h>
28
41
 
29
42
  #include "stitcher_proxy_jsi.hpp"
43
+ // v0.11.1 — worklets-core JsiWorkletContext. We initialize the
44
+ // SINGLETON default instance here so that other contexts in this
45
+ // library that use the 2-arg `JsiWorkletContext(name, workletInvoker)`
46
+ // constructor inherit a working `_jsCallInvoker` (and thus their
47
+ // `runOnJS` / `Worklets.createRunOnJS` callbacks actually route back
48
+ // to the main JS thread). Specifically: `RNSARWorkletRuntime`'s AR-
49
+ // side worklet context (see `RNSARWorkletRuntime.mm:155`) uses the
50
+ // 2-arg ctor; pre-v0.11.1 that left its inherited `_jsCallInvoker`
51
+ // nullptr, and `invokeOnJsThread` silently no-op'd (see
52
+ // `WKTJsiWorkletContext.cpp:124-131`). Test 2 of the v0.11.0
53
+ // manual-verification checklist surfaced this as "AR-mode host
54
+ // worklets register but their runOnJS callbacks never fire."
55
+ #include "WKTJsiWorkletContext.h"
30
56
 
31
57
  using namespace facebook;
32
58
 
@@ -94,9 +120,40 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(install) {
94
120
  jsi::Runtime& runtime = *(jsi::Runtime*)cxxBridge.runtime;
95
121
  retailens::installStitcherProxy(runtime);
96
122
 
123
+ // v0.11.1 — initialize the singleton default JsiWorkletContext so
124
+ // that downstream 2-arg ctors (RNSARWorkletRuntime) inherit a
125
+ // working `_jsCallInvoker`. Without this, AR-mode host worklets'
126
+ // `runOnJS` / `Worklets.createRunOnJS` callbacks silently no-op
127
+ // (`WKTJsiWorkletContext.cpp:124-131` early-returns when
128
+ // `_jsCallInvoker == nullptr`). See file-top comment for the full
129
+ // diagnosis (Test 2 of v0.11.0 manual-verification checklist).
130
+ //
131
+ // Idempotent at the worklets-core level: re-initialization is
132
+ // tolerated; the default instance is a process-scope singleton
133
+ // and we're called once per JS-runtime bootstrap. In bridgeless
134
+ // mode `cxxBridge.jsCallInvoker` is forwarded via RCTBridgeProxy
135
+ // to the underlying RCTHost's `CallInvoker` (same forwarding
136
+ // pattern as `cxxBridge.runtime` above).
137
+ auto jsCallInvoker = cxxBridge.jsCallInvoker;
138
+ if (jsCallInvoker == nullptr) {
139
+ os_log_error(OS_LOG_DEFAULT,
140
+ "[StitcherJsiInstaller] cxxBridge.jsCallInvoker is nullptr; "
141
+ "AR-mode host worklets' runOnJS will not fire. Proxy installed "
142
+ "but worklet-bridging is impaired.");
143
+ // Proxy is still installed; only the runOnJS path is impaired.
144
+ // Return @YES so JS callers don't fall back to the JS-side registry.
145
+ return @YES;
146
+ }
147
+ auto jsInvokerAdapter =
148
+ [jsCallInvoker](std::function<void()>&& fp) {
149
+ jsCallInvoker->invokeAsync(std::move(fp));
150
+ };
151
+ RNWorklet::JsiWorkletContext::getDefaultInstance()->initialize(
152
+ "stitcher.default", &runtime, jsInvokerAdapter);
153
+
97
154
  os_log_info(OS_LOG_DEFAULT,
98
155
  "[StitcherJsiInstaller] installed globalThis.__stitcherProxy "
99
- "on main JS runtime.");
156
+ "AND initialized default JsiWorkletContext on main JS runtime.");
100
157
  return @YES;
101
158
  }
102
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
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",
@@ -53,6 +53,7 @@
53
53
  },
54
54
  "homepage": "https://github.com/bhargavkanda/react-native-image-stitcher#readme",
55
55
  "devDependencies": {
56
+ "@react-native-community/cli": "20.1.0",
56
57
  "@types/jest": "^29.5.0",
57
58
  "@types/react": "^19.0.0",
58
59
  "jest": "^29.7.0",
@@ -0,0 +1,202 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Unit tests for `useStitcherWorklet`.
4
+ *
5
+ * Coverage focus (v0.11.1):
6
+ *
7
+ * - **AR-source short-circuit.** The hook's docstring promises
8
+ * that AR-mode hosts can call `stitcher.call(frame)` from a
9
+ * single composed worklet body without per-mode branching; AR
10
+ * stitching runs natively via the AR-side dispatcher. Pre-
11
+ * v0.11.1 the code didn't enforce that — `stitcher.call` would
12
+ * invoke the vc Frame Processor plugin even on AR-source
13
+ * frames, which throws `getPropertyAsObject: property '__frame'
14
+ * is undefined` because AR frames are `StitcherFrameHostObject`
15
+ * instances and don't carry vc's JSI `Frame` proxy marker. The
16
+ * throw was caught silently by the per-worklet error handler in
17
+ * `RNSARWorkletRuntime.mm`, surfacing only as an `os_log` entry
18
+ * — invisible to JS, which is why composed hosts saw their
19
+ * post-`stitcher.call` lines (`fireFrameProcessorLog`,
20
+ * `runOnJS` callbacks) silently never execute in AR mode. Test
21
+ * 2 of `docs/v0.11.0-manual-verification-checklist.md`
22
+ * reproduced this on Ram's iPhone. This test pins the fix.
23
+ *
24
+ * - **vc-source happy path.** vc-source frames (and frames whose
25
+ * `source` is `undefined` — which is what vc's raw `Frame`
26
+ * looks like; the lib doesn't wrap vc frames in Phase 4a) MUST
27
+ * still invoke the plugin.
28
+ *
29
+ * ## Why mock React's hooks directly
30
+ *
31
+ * The hook owns state via `useState` (the JSI plugin handle) and
32
+ * side effects via `useEffect` (plugin acquisition retry loop + gyro
33
+ * subscription). The existing test pattern in this directory (see
34
+ * `useThrottledFrameProcessor.test.ts`) doesn't use a React renderer
35
+ * — instead it mocks the hooks the SUT calls so the SUT can be
36
+ * executed as a plain function. Same approach here: we mock
37
+ * `useState` to return a pre-resolved plugin, `useCallback` to
38
+ * return the function as-is, `useEffect` as a no-op (we don't need
39
+ * the plugin-acquisition retry or gyro for the call-routing test).
40
+ */
41
+
42
+ import type { StitcherFrame } from '../StitcherFrame';
43
+
44
+ // ─── Mock vision-camera ──────────────────────────────────────────
45
+ const pluginCallSpy = jest.fn();
46
+ const fakePlugin = { call: pluginCallSpy } as unknown as object;
47
+
48
+ jest.mock('react-native-vision-camera', () => ({
49
+ VisionCameraProxy: {
50
+ initFrameProcessorPlugin: jest.fn(() => fakePlugin),
51
+ },
52
+ }));
53
+
54
+ // ─── Mock react-native-worklets-core ─────────────────────────────
55
+ jest.mock('react-native-worklets-core', () => ({
56
+ useSharedValue: (initial: number) => ({ value: initial }),
57
+ }));
58
+
59
+ // ─── Mock react-native-sensors ───────────────────────────────────
60
+ jest.mock('react-native-sensors', () => ({
61
+ gyroscope: { subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })) },
62
+ setUpdateIntervalForType: jest.fn(),
63
+ SensorTypes: { gyroscope: 'gyroscope' },
64
+ }));
65
+
66
+ // ─── Mock React's hooks so the SUT runs as a plain function ──────
67
+ //
68
+ // `useState` returns the plugin pre-resolved. `useCallback` returns
69
+ // the function identity (deps array ignored — we're not testing
70
+ // re-render semantics). `useEffect` is a no-op (no plugin retry,
71
+ // no gyro subscription). This lets us call the hook synchronously
72
+ // and exercise the worklet body via the returned `call` function.
73
+ jest.mock('react', () => {
74
+ const actual = jest.requireActual('react');
75
+ return {
76
+ ...actual,
77
+ useState: <T,>(initial: T): [T, (next: T) => void] => {
78
+ // For the [plugin, setPlugin] tuple: return the fake plugin
79
+ // immediately rather than starting at `null`. This skips the
80
+ // plugin-acquisition retry path and lets `call` actually
81
+ // invoke `plugin.call(...)`.
82
+ const resolved = (initial === null ? fakePlugin : initial) as T;
83
+ return [resolved, () => {}];
84
+ },
85
+ useEffect: () => {},
86
+ useCallback: <T,>(fn: T): T => fn,
87
+ };
88
+ });
89
+
90
+ // SUT — imported AFTER mocks so the hook sees them.
91
+ // eslint-disable-next-line import/first
92
+ import { useStitcherWorklet } from '../useStitcherWorklet';
93
+
94
+ describe('useStitcherWorklet', () => {
95
+ beforeEach(() => {
96
+ pluginCallSpy.mockReset();
97
+ });
98
+
99
+ describe('AR-source short-circuit (v0.11.1 fix)', () => {
100
+ it('does NOT invoke the vc plugin for AR-source frames', () => {
101
+ const { call } = useStitcherWorklet();
102
+ const arFrame: StitcherFrame = {
103
+ width: 1920,
104
+ height: 1080,
105
+ pixelFormat: 'yuv',
106
+ orientation: 'landscape-right',
107
+ timestamp: 0,
108
+ toArrayBuffer: () => new ArrayBuffer(0),
109
+ source: 'ar',
110
+ pose: { rotation: [0, 0, 0, 1] },
111
+ };
112
+ call(arFrame);
113
+ expect(pluginCallSpy).not.toHaveBeenCalled();
114
+ });
115
+
116
+ it('does NOT invoke the vc plugin for AR-source frames even when called repeatedly', () => {
117
+ const { call } = useStitcherWorklet();
118
+ const arFrame: StitcherFrame = {
119
+ width: 1920,
120
+ height: 1080,
121
+ pixelFormat: 'yuv',
122
+ orientation: 'landscape-right',
123
+ timestamp: 0,
124
+ toArrayBuffer: () => new ArrayBuffer(0),
125
+ source: 'ar',
126
+ pose: { rotation: [0, 0, 0, 1] },
127
+ };
128
+ for (let i = 0; i < 30; i++) call(arFrame);
129
+ expect(pluginCallSpy).not.toHaveBeenCalled();
130
+ });
131
+ });
132
+
133
+ describe('vc-source happy path', () => {
134
+ it('invokes the vc plugin for vc-source frames', () => {
135
+ const { call } = useStitcherWorklet();
136
+ const vcFrame: StitcherFrame = {
137
+ width: 1920,
138
+ height: 1080,
139
+ pixelFormat: 'yuv',
140
+ orientation: 'landscape-right',
141
+ timestamp: 0,
142
+ toArrayBuffer: () => new ArrayBuffer(0),
143
+ source: 'vc',
144
+ pose: { rotation: [0, 0, 0, 1] },
145
+ };
146
+ call(vcFrame);
147
+ expect(pluginCallSpy).toHaveBeenCalledTimes(1);
148
+ });
149
+
150
+ it('invokes the vc plugin for frames with undefined source (raw vc Frame)', () => {
151
+ // vc's raw `Frame` doesn't carry the `source` field — the lib's
152
+ // Phase 4a deferral means we don't wrap vc frames into
153
+ // `StitcherFrame`. The AR-source check must treat undefined
154
+ // as "not AR" to preserve the non-AR worklet path.
155
+ const { call } = useStitcherWorklet();
156
+ const rawVcFrame = {
157
+ width: 1920,
158
+ height: 1080,
159
+ pixelFormat: 'yuv',
160
+ orientation: 'landscape-right',
161
+ timestamp: 0,
162
+ toArrayBuffer: () => new ArrayBuffer(0),
163
+ // `source` intentionally absent
164
+ } as unknown as StitcherFrame;
165
+ call(rawVcFrame);
166
+ expect(pluginCallSpy).toHaveBeenCalledTimes(1);
167
+ });
168
+ });
169
+
170
+ describe('plugin.call payload shape', () => {
171
+ it('passes the frame + a numeric-intrinsics params object', () => {
172
+ const { call } = useStitcherWorklet();
173
+ const vcFrame: StitcherFrame = {
174
+ width: 1920,
175
+ height: 1080,
176
+ pixelFormat: 'yuv',
177
+ orientation: 'landscape-right',
178
+ timestamp: 0,
179
+ toArrayBuffer: () => new ArrayBuffer(0),
180
+ source: 'vc',
181
+ pose: { rotation: [0, 0, 0, 1] },
182
+ };
183
+ call(vcFrame);
184
+ expect(pluginCallSpy).toHaveBeenCalledWith(
185
+ vcFrame,
186
+ expect.objectContaining({
187
+ tx: 0, ty: 0, tz: 0,
188
+ qx: expect.any(Number),
189
+ qy: expect.any(Number),
190
+ qz: expect.any(Number),
191
+ qw: expect.any(Number),
192
+ fx: expect.any(Number),
193
+ fy: expect.any(Number),
194
+ cx: 960,
195
+ cy: 540,
196
+ imageWidth: 1920,
197
+ imageHeight: 1080,
198
+ }),
199
+ );
200
+ });
201
+ });
202
+ });
@@ -336,6 +336,31 @@ export function useStitcherWorklet(
336
336
  'worklet';
337
337
  if (plugin == null) return;
338
338
 
339
+ // v0.11.1 — AR-source frames are stitched natively by the AR-
340
+ // side dispatcher (`RNSARSession.swift:510-511` → the first-
341
+ // party callback installed in `RNSARWorkletRuntime`). Calling
342
+ // the vc Frame Processor plugin here would throw
343
+ // `getPropertyAsObject: property '__frame' is undefined`
344
+ // because AR frames are `StitcherFrameHostObject` instances
345
+ // and don't carry the vc `Frame` proxy's JSI marker. The
346
+ // throw is caught silently by the per-worklet error handler
347
+ // (`RNSARWorkletRuntime.mm:284-301`) and bubbles up only to
348
+ // `os_log` — invisible to JS, which is why pre-v0.11.1
349
+ // composed hosts saw their post-`stitcher.call` lines
350
+ // (`fireFrameProcessorLog`, `runOnJS` callbacks) silently
351
+ // never execute in AR mode. Silent no-op here matches the
352
+ // module-header promise that AR mode is "unaffected" by this
353
+ // hook (the AR-side stitching path runs natively, independent
354
+ // of the composed worklet body).
355
+ //
356
+ // The `(frame as StitcherFrame).source` cast is safe: vc
357
+ // `Frame` doesn't carry a `source` property so the check
358
+ // returns `undefined !== 'ar'` → `true`, and the worklet
359
+ // proceeds normally. Only frames that explicitly tag
360
+ // themselves as AR-source (which our native AR dispatcher
361
+ // does — see `StitcherFrameHostObject.mm`) get short-circuited.
362
+ if ((frame as StitcherFrame).source === 'ar') return;
363
+
339
364
  // Throttle (verbatim from useFrameProcessorDriver).
340
365
  sharedFrameCounter.value += 1;
341
366
  const N = sharedEvalEveryN.value;