react-native-image-stitcher 0.10.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.
@@ -0,0 +1,300 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * useStitcherWorklet — exposes the lib's first-party stitching as a
5
+ * callable worklet function for host-composed Frame Processors.
6
+ *
7
+ * v0.11.0 — closes the v0.8.0 Phase 5 either-or constraint by letting
8
+ * hosts COMPOSE: write ONE `useFrameProcessor` worklet body that calls
9
+ * BOTH your custom logic AND the lib's first-party stitching, instead
10
+ * of one displacing the other. See `docs/host-app-integration.md`
11
+ * § Tier 3 composition for the pattern.
12
+ *
13
+ * ## Why this is a separate hook
14
+ *
15
+ * vision-camera v4 lets a `<Camera>` mount accept exactly ONE frame
16
+ * processor. Pre-v0.11.0, hosts that passed a `frameProcessor` prop
17
+ * to the lib's `<Camera>` REPLACED the lib's first-party stitching
18
+ * processor in non-AR mode. Composing required hand-writing both
19
+ * worklet bodies in the host's processor. v0.11.0 extracts the
20
+ * lib's worklet body into this hook so hosts can compose with a
21
+ * single call:
22
+ *
23
+ * const stitcher = useStitcherWorklet();
24
+ * const fp = useFrameProcessor((frame) => {
25
+ * 'worklet';
26
+ * hostPreLogic(frame);
27
+ * stitcher.call(frame); // ← lib's first-party stitching
28
+ * hostPostLogic(frame);
29
+ * }, [stitcher.call]);
30
+ * return <Camera frameProcessor={fp} ... />;
31
+ *
32
+ * AR mode is unaffected — the AR-session dispatch path (v0.8.0 Phase
33
+ * 4b.i / 4b.iii) already composes natively.
34
+ *
35
+ * ## What this owns
36
+ *
37
+ * - vc Frame Processor plugin acquisition for
38
+ * `cv_flow_gate_process_frame` (the same plugin the legacy
39
+ * `useFrameProcessorDriver` used; reentrant by construction).
40
+ * - Shared values backing pose (yaw / pitch / roll), throttle
41
+ * counter, every-N gate, and FoV-derived intrinsics scalars.
42
+ * - Gyro subscription on the JS thread (always-on between mount
43
+ * and unmount; subscription cost is tiny).
44
+ * - The worklet body itself: throttle → pose synthesis →
45
+ * `plugin.call(frame, params)`.
46
+ *
47
+ * ## Lifecycle
48
+ *
49
+ * - Gyro auto-subscribes on mount, auto-unsubscribes on unmount.
50
+ * Composed hosts get pose tracking for free.
51
+ * - `reset()` zeros the accumulated yaw / pitch / roll between
52
+ * captures. `useFrameProcessorDriver` calls this on `start()` to
53
+ * preserve pre-v0.11.0 per-capture pose-reset behaviour;
54
+ * composed hosts should call it at the start of each capture too
55
+ * (otherwise pose drifts across captures).
56
+ *
57
+ * ## Behaviour delta from pre-v0.11.0
58
+ *
59
+ * Before: `useFrameProcessorDriver.start()` subscribed the gyro;
60
+ * `stop()` unsubscribed. The subscription was tied to the
61
+ * capture lifecycle.
62
+ *
63
+ * After: the gyro is subscribed for the lifetime of this hook
64
+ * (i.e., as long as the component using it is mounted). In the
65
+ * default `<Camera>` integration the hook mounts when the camera
66
+ * screen mounts, so the practical effect is the same; in
67
+ * custom-composed integrations the host controls mount/unmount
68
+ * by mounting/unmounting the component that calls
69
+ * `useStitcherWorklet`. The battery delta is small: gyroscope
70
+ * sampling at 33ms costs ≪1% CPU on every Android/iOS device
71
+ * the lib supports.
72
+ *
73
+ * `pose reset` semantics are preserved via the new explicit
74
+ * `reset()` method. Hosts that previously relied on `start()`
75
+ * to zero pose now call `stitcher.reset()` at the capture start.
76
+ *
77
+ * ## Pose synthesis (verbatim from `useFrameProcessorDriver`)
78
+ *
79
+ * Quaternion: q = q_yaw * q_pitch * q_roll (Tait-Bryan YPR, body
80
+ * frame). Expanded:
81
+ * qx = cy*sp*cr + sy*cp*sr
82
+ * qy = sy*cp*cr - cy*sp*sr
83
+ * qz = cy*cp*sr - sy*sp*cr
84
+ * qw = cy*cp*cr + sy*sp*sr
85
+ *
86
+ * When roll=0 this collapses to the legacy 2-axis form so captures
87
+ * held level produce bit-identical poses to the pre-v0.6 driver
88
+ * (and bit-identical to v0.10.x's `useFrameProcessorDriver`).
89
+ *
90
+ * ## Throttling (verbatim)
91
+ *
92
+ * `evalEveryNFrames` controls how often the worklet calls the
93
+ * plugin. Default 1. Independent of — and stacks on top of —
94
+ * the stitcher's own internal `flowEvalEveryNFrames` in
95
+ * `KeyframeGate.swift`; effective cadence is the product.
96
+ *
97
+ * ## Pairing with `IncrementalStitcher.start`
98
+ *
99
+ * The plugin's per-frame call into `consumeFrameFromPlugin` is
100
+ * gated by `IncrementalStitcher.frameProcessorIngestEnabled`,
101
+ * which is TRUE only when the stitcher was started with
102
+ * `frameSourceMode === 'frameProcessor'`. Hosts MUST call
103
+ * `incrementalStitcher.start({ frameSourceMode: 'frameProcessor',
104
+ * ... })` to actually get frames into the engine — otherwise the
105
+ * worklet runs to completion but the wrapper drops the call.
106
+ * `Camera.tsx` does this wiring automatically when the host opts
107
+ * into the lib's `useFrameProcessorDriver`. Hosts that compose
108
+ * their own worklet via this hook must do the wiring themselves.
109
+ */
110
+ Object.defineProperty(exports, "__esModule", { value: true });
111
+ exports.useStitcherWorklet = useStitcherWorklet;
112
+ const react_1 = require("react");
113
+ const react_native_sensors_1 = require("react-native-sensors");
114
+ // Reanimated's `useSharedValue` is the documented vision-camera
115
+ // idiom, but it's a heavy peer dep. `react-native-worklets-core`
116
+ // (already a transitive dep via vision-camera v4 on RN 0.84) exposes
117
+ // the same API surface (a `value` getter/setter readable from
118
+ // worklets and the JS thread) and is sufficient for our use.
119
+ const react_native_worklets_core_1 = require("react-native-worklets-core");
120
+ const react_native_vision_camera_1 = require("react-native-vision-camera");
121
+ function useStitcherWorklet(options = {}) {
122
+ const { gyroIntervalMs = 33, fovHorizDegrees = 65, fovVertDegrees = 50, evalEveryNFrames = 1, } = options;
123
+ // ── Plugin acquisition ──────────────────────────────────────────
124
+ //
125
+ // `initFrameProcessorPlugin` can return `undefined` if called
126
+ // before vision-camera's plugin registry has finished initialising
127
+ // (race observed in F8.1.a). Mount-once useEffect with a 16ms
128
+ // retry until success. Verbatim from `useFrameProcessorDriver`.
129
+ const [plugin, setPlugin] = (0, react_1.useState)(null);
130
+ (0, react_1.useEffect)(() => {
131
+ let cancelled = false;
132
+ let timerId = null;
133
+ const tryAcquire = () => {
134
+ if (cancelled)
135
+ return;
136
+ const p = react_native_vision_camera_1.VisionCameraProxy.initFrameProcessorPlugin('cv_flow_gate_process_frame', {});
137
+ if (p != null) {
138
+ setPlugin(p);
139
+ return;
140
+ }
141
+ timerId = setTimeout(tryAcquire, 16);
142
+ };
143
+ tryAcquire();
144
+ return () => {
145
+ cancelled = true;
146
+ if (timerId != null)
147
+ clearTimeout(timerId);
148
+ };
149
+ // eslint-disable-next-line react-hooks/exhaustive-deps
150
+ }, []);
151
+ // ── Shared values (worklet ↔ JS thread) ─────────────────────────
152
+ const sharedYaw = (0, react_native_worklets_core_1.useSharedValue)(0);
153
+ const sharedPitch = (0, react_native_worklets_core_1.useSharedValue)(0);
154
+ const sharedRoll = (0, react_native_worklets_core_1.useSharedValue)(0);
155
+ const sharedFrameCounter = (0, react_native_worklets_core_1.useSharedValue)(0);
156
+ const sharedEvalEveryN = (0, react_native_worklets_core_1.useSharedValue)(Math.max(1, evalEveryNFrames));
157
+ const sharedFxNumerator = (0, react_native_worklets_core_1.useSharedValue)(1.0 / (2.0 * Math.tan((fovHorizDegrees * Math.PI / 180) / 2)));
158
+ const sharedFyNumerator = (0, react_native_worklets_core_1.useSharedValue)(1.0 / (2.0 * Math.tan((fovVertDegrees * Math.PI / 180) / 2)));
159
+ // Prop-derived shared values stay in sync via cheap effects.
160
+ (0, react_1.useEffect)(() => {
161
+ sharedEvalEveryN.value = Math.max(1, evalEveryNFrames);
162
+ }, [evalEveryNFrames, sharedEvalEveryN]);
163
+ (0, react_1.useEffect)(() => {
164
+ sharedFxNumerator.value =
165
+ 1.0 / (2.0 * Math.tan((fovHorizDegrees * Math.PI / 180) / 2));
166
+ }, [fovHorizDegrees, sharedFxNumerator]);
167
+ (0, react_1.useEffect)(() => {
168
+ sharedFyNumerator.value =
169
+ 1.0 / (2.0 * Math.tan((fovVertDegrees * Math.PI / 180) / 2));
170
+ }, [fovVertDegrees, sharedFyNumerator]);
171
+ // ── Gyro subscription (always-on while mounted) ─────────────────
172
+ //
173
+ // v0.11.0 — moved here from `useFrameProcessorDriver.start()`.
174
+ // The composition pattern needs gyro running whenever
175
+ // `useStitcherWorklet` is in use; gating the subscription on a
176
+ // separate start/stop pair would force every composed host to
177
+ // wire its own lifecycle. Cost is tiny: ≪1% CPU at 33ms
178
+ // sampling. See module header "Behaviour delta from pre-v0.11.0".
179
+ (0, react_1.useEffect)(() => {
180
+ let lastGyroAt = null;
181
+ (0, react_native_sensors_1.setUpdateIntervalForType)(react_native_sensors_1.SensorTypes.gyroscope, gyroIntervalMs);
182
+ const sub = react_native_sensors_1.gyroscope.subscribe({
183
+ next: ({ x, y, z }) => {
184
+ const now = Date.now();
185
+ if (lastGyroAt === null) {
186
+ lastGyroAt = now;
187
+ return;
188
+ }
189
+ const dt = (now - lastGyroAt) / 1000.0;
190
+ lastGyroAt = now;
191
+ sharedYaw.value += y * dt;
192
+ sharedPitch.value += x * dt;
193
+ sharedRoll.value += z * dt;
194
+ },
195
+ error: (err) => {
196
+ // eslint-disable-next-line no-console
197
+ console.warn('[useStitcherWorklet] gyro error', err);
198
+ },
199
+ });
200
+ return () => {
201
+ sub.unsubscribe();
202
+ };
203
+ }, [gyroIntervalMs, sharedYaw, sharedPitch, sharedRoll]);
204
+ // ── Explicit reset (for per-capture pose zero-ing) ──────────────
205
+ const reset = (0, react_1.useCallback)(() => {
206
+ sharedYaw.value = 0;
207
+ sharedPitch.value = 0;
208
+ sharedRoll.value = 0;
209
+ sharedFrameCounter.value = 0;
210
+ }, [sharedYaw, sharedPitch, sharedRoll, sharedFrameCounter]);
211
+ // ── Worklet body ────────────────────────────────────────────────
212
+ //
213
+ // Returned as `handle.call`. Re-created when `plugin` changes
214
+ // (which happens at most once at acquire time); deps array on the
215
+ // useCallback ensures consumers' `useFrameProcessor([handle.call])`
216
+ // re-binds when the worklet identity changes.
217
+ //
218
+ // The `'worklet'` directive marks this function for the
219
+ // worklets-core transformer so it can be serialised into the
220
+ // producer-thread runtime; that's the contract that lets a host
221
+ // `useFrameProcessor` worklet body call it without a thread hop.
222
+ const call = (0, react_1.useCallback)((frame) => {
223
+ 'worklet';
224
+ if (plugin == null)
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;
251
+ // Throttle (verbatim from useFrameProcessorDriver).
252
+ sharedFrameCounter.value += 1;
253
+ const N = sharedEvalEveryN.value;
254
+ if (N > 1 && (sharedFrameCounter.value % N) !== 0)
255
+ return;
256
+ // Pose synthesis (verbatim from useFrameProcessorDriver).
257
+ const halfYaw = sharedYaw.value / 2;
258
+ const halfPitch = sharedPitch.value / 2;
259
+ const halfRoll = sharedRoll.value / 2;
260
+ const cy_ = Math.cos(halfYaw);
261
+ const sy_ = Math.sin(halfYaw);
262
+ const cp = Math.cos(halfPitch);
263
+ const sp = Math.sin(halfPitch);
264
+ const cr = Math.cos(halfRoll);
265
+ const sr = Math.sin(halfRoll);
266
+ const qx = cy_ * sp * cr + sy_ * cp * sr;
267
+ const qy = sy_ * cp * cr - cy_ * sp * sr;
268
+ const qz = cy_ * cp * sr - sy_ * sp * cr;
269
+ const qw = cy_ * cp * cr + sy_ * sp * sr;
270
+ // Intrinsics from FoV + actual frame dims.
271
+ const w = frame.width;
272
+ const h = frame.height;
273
+ const fx = w * sharedFxNumerator.value;
274
+ const fy = h * sharedFyNumerator.value;
275
+ // vc's `plugin.call` is typed against vc's `Frame`. The worklet
276
+ // accepts the union (`Frame | StitcherFrame`); cast through
277
+ // `unknown` because the union doesn't satisfy vc's interface
278
+ // even though structurally both members do.
279
+ plugin.call(frame, {
280
+ tx: 0, ty: 0, tz: 0,
281
+ qx, qy, qz, qw,
282
+ fx, fy,
283
+ cx: w / 2, cy: h / 2,
284
+ imageWidth: w, imageHeight: h,
285
+ timestampMs: 0,
286
+ trackingStateRaw: 2, // RNSARTrackingState.tracking (no AR signal in non-AR mode)
287
+ });
288
+ }, [
289
+ plugin,
290
+ sharedFrameCounter,
291
+ sharedEvalEveryN,
292
+ sharedYaw,
293
+ sharedPitch,
294
+ sharedRoll,
295
+ sharedFxNumerator,
296
+ sharedFyNumerator,
297
+ ]);
298
+ return { call, reset, isReady: plugin != null };
299
+ }
300
+ //# sourceMappingURL=useStitcherWorklet.js.map
@@ -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.10.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",
@@ -323,26 +323,42 @@ export interface CameraProps {
323
323
  * }
324
324
  * ```
325
325
  *
326
- * ## Non-AR mode tradeoff (HONEST)
326
+ * ## Non-AR mode composition (v0.11.0+)
327
327
  *
328
328
  * vision-camera's `<Camera>` accepts ONLY ONE frame processor.
329
329
  * The lib's internal `useFrameProcessorDriver` produces the
330
330
  * processor that drives first-party panorama stitching in non-AR
331
- * mode. If you supply your own via this prop, **the lib's
332
- * first-party stitching is replaced**panorama capture in
333
- * non-AR mode will not produce stitched output until you remove
334
- * the prop or fork the SDK to compose both worklets manually.
331
+ * mode. If you supply your own via this prop, the lib's
332
+ * default processor is REPLACEDbut as of v0.11.0 you can
333
+ * COMPOSE first-party stitching back into your worklet body
334
+ * using `useStitcherWorklet`:
335
335
  *
336
- * For the common case (host wants worklet + lib wants stitching
337
- * concurrently), prefer AR mode: the AR-mode path natively fans
338
- * out to both the lib's first-party stitching AND every
339
- * registered host worklet on every frame, with per-worklet
340
- * failure isolation.
336
+ * ```tsx
337
+ * import {
338
+ * Camera, useFrameProcessor, useStitcherWorklet,
339
+ * type StitcherFrame,
340
+ * } from 'react-native-image-stitcher';
341
341
  *
342
- * Composition for non-AR mode (lib stitching + host worklet on
343
- * the same vc processor) is tracked as a v0.9+ follow-up;
344
- * needs the lib's first-party logic exposed as a vc Frame
345
- * Processor plugin the host's worklet can call.
342
+ * function MyScreen() {
343
+ * const stitcher = useStitcherWorklet();
344
+ * const fp = useFrameProcessor((frame: StitcherFrame) => {
345
+ * 'worklet';
346
+ * hostPreLogic(frame);
347
+ * stitcher.call(frame); // ← first-party stitching
348
+ * hostPostLogic(frame);
349
+ * }, [stitcher.call]);
350
+ * return <Camera frameProcessor={fp} ... />;
351
+ * }
352
+ * ```
353
+ *
354
+ * Hosts that DON'T call `useStitcherWorklet` from their worklet
355
+ * body replace first-party stitching for non-AR captures (a
356
+ * one-shot console.info documents this when the prop is first
357
+ * supplied). AR mode is unaffected either way — the AR-mode
358
+ * dispatch path (v0.8.0 Phase 4b.i / 4b.iii) natively fans out
359
+ * to both the lib's first-party stitching AND every registered
360
+ * host worklet on every frame, with per-worklet failure
361
+ * isolation.
346
362
  *
347
363
  * ## AR mode behaviour
348
364
  *
@@ -841,24 +857,23 @@ export function Camera(props: CameraProps): React.JSX.Element {
841
857
  // eslint-disable-next-line react-hooks/exhaustive-deps
842
858
  useEffect(() => () => { fpDriver.stop(); }, []);
843
859
 
844
- // v0.8.0 Phase 5 — frameProcessor prop semantics:
860
+ // v0.8.0 Phase 5 / v0.11.0 — frameProcessor prop semantics:
845
861
  //
846
- // - Host supplied? → use host's processor; lib's first-party
847
- // stitching is DISABLED in non-AR mode (vc accepts only one
848
- // processor). One-shot console.info documents the tradeoff
849
- // so the host isn't surprised by "panorama capture stopped
850
- // producing output" in non-AR mode. AR-mode capture is
851
- // unaffected the AR-session dispatch path fans out to BOTH
852
- // first-party and host worklets independently.
862
+ // - Host supplied? → use host's processor. The host's worklet
863
+ // body controls whether first-party stitching also fires:
864
+ // call `stitcher.call(frame)` (from `useStitcherWorklet`)
865
+ // inside the body to compose; omit to replace. One-shot
866
+ // console.info documents the choice so the host can spot a
867
+ // missing `useStitcherWorklet` call before they go hunting
868
+ // for "why is non-AR panorama capture not producing output".
869
+ // AR-mode capture is unaffected either way — the AR-session
870
+ // dispatch path fans out to BOTH first-party stitching AND
871
+ // every host worklet independently.
853
872
  //
854
873
  // - No host processor? → use `fpDriver.frameProcessor` which is
855
874
  // the lib's internal worklet driving first-party stitching
856
875
  // via `useFrameProcessorDriver`. Default behaviour for the
857
876
  // common "I just want panorama capture" case.
858
- //
859
- // The pre-v0.8.0 behaviour (host's prop silently ignored with
860
- // a warning) is gone — Phase 5 plumbs the prop through. The
861
- // tradeoff is honestly documented in the CameraProps docstring.
862
877
  const hostFrameProcessorAcceptedWarnedRef = useRef(false);
863
878
  if (
864
879
  hostFrameProcessor != null
@@ -868,12 +883,13 @@ export function Camera(props: CameraProps): React.JSX.Element {
868
883
  // eslint-disable-next-line no-console
869
884
  console.info(
870
885
  '[react-native-image-stitcher] Host frameProcessor supplied — '
871
- + 'non-AR mode will run YOUR worklet instead of the lib\'s '
872
- + 'first-party stitching plugin (vc accepts only one frame '
873
- + 'processor). Non-AR panorama capture will not produce '
874
- + 'stitched output until this prop is removed. AR-mode '
875
- + 'capture is unaffected (AR-session dispatch fans out to '
876
- + 'both first-party and host worklets independently).',
886
+ + 'non-AR mode will run YOUR composed worklet. If you want '
887
+ + 'first-party panorama stitching alongside your own logic, '
888
+ + 'call `useStitcherWorklet()` and invoke `stitcher.call(frame)` '
889
+ + 'from your worklet body (see `<Camera>` `frameProcessor` '
890
+ + 'JSDoc for the composition pattern). AR-mode capture is '
891
+ + 'unaffected (AR-session dispatch fans out to both '
892
+ + 'first-party and host worklets independently).',
877
893
  );
878
894
  }
879
895
  // The Frame Processor worklet bound to vision-camera's Camera.
package/src/index.ts CHANGED
@@ -226,6 +226,19 @@ export type {
226
226
  FrameProcessorDriverHandle,
227
227
  } from './stitching/useFrameProcessorDriver';
228
228
 
229
+ // v0.11.0 — composable first-party stitching as a worklet function.
230
+ // Hosts that want to COMPOSE their own per-frame logic with the
231
+ // lib's stitching (instead of REPLACING it via the <Camera>
232
+ // `frameProcessor` prop) call this hook + invoke `stitcher.call`
233
+ // inside their own `useFrameProcessor` body. See
234
+ // `docs/host-app-integration.md` § Tier 3 for the full pattern.
235
+ export { useStitcherWorklet } from './stitching/useStitcherWorklet';
236
+ export type {
237
+ UseStitcherWorkletOptions,
238
+ StitcherWorkletHandle,
239
+ StitcherWorkletInput,
240
+ } from './stitching/useStitcherWorklet';
241
+
229
242
  // ── Batch stitching ───────────────────────────────────────────────────
230
243
  // Feed a video file straight to OpenCV's cv::Stitcher, bypassing the
231
244
  // incremental pipeline. Useful when you have content captured