react-native-image-stitcher 0.10.0 → 0.11.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.
@@ -0,0 +1,390 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * useStitcherWorklet — exposes the lib's first-party stitching as a
4
+ * callable worklet function for host-composed Frame Processors.
5
+ *
6
+ * v0.11.0 — closes the v0.8.0 Phase 5 either-or constraint by letting
7
+ * hosts COMPOSE: write ONE `useFrameProcessor` worklet body that calls
8
+ * BOTH your custom logic AND the lib's first-party stitching, instead
9
+ * of one displacing the other. See `docs/host-app-integration.md`
10
+ * § Tier 3 composition for the pattern.
11
+ *
12
+ * ## Why this is a separate hook
13
+ *
14
+ * vision-camera v4 lets a `<Camera>` mount accept exactly ONE frame
15
+ * processor. Pre-v0.11.0, hosts that passed a `frameProcessor` prop
16
+ * to the lib's `<Camera>` REPLACED the lib's first-party stitching
17
+ * processor in non-AR mode. Composing required hand-writing both
18
+ * worklet bodies in the host's processor. v0.11.0 extracts the
19
+ * lib's worklet body into this hook so hosts can compose with a
20
+ * single call:
21
+ *
22
+ * const stitcher = useStitcherWorklet();
23
+ * const fp = useFrameProcessor((frame) => {
24
+ * 'worklet';
25
+ * hostPreLogic(frame);
26
+ * stitcher.call(frame); // ← lib's first-party stitching
27
+ * hostPostLogic(frame);
28
+ * }, [stitcher.call]);
29
+ * return <Camera frameProcessor={fp} ... />;
30
+ *
31
+ * AR mode is unaffected — the AR-session dispatch path (v0.8.0 Phase
32
+ * 4b.i / 4b.iii) already composes natively.
33
+ *
34
+ * ## What this owns
35
+ *
36
+ * - vc Frame Processor plugin acquisition for
37
+ * `cv_flow_gate_process_frame` (the same plugin the legacy
38
+ * `useFrameProcessorDriver` used; reentrant by construction).
39
+ * - Shared values backing pose (yaw / pitch / roll), throttle
40
+ * counter, every-N gate, and FoV-derived intrinsics scalars.
41
+ * - Gyro subscription on the JS thread (always-on between mount
42
+ * and unmount; subscription cost is tiny).
43
+ * - The worklet body itself: throttle → pose synthesis →
44
+ * `plugin.call(frame, params)`.
45
+ *
46
+ * ## Lifecycle
47
+ *
48
+ * - Gyro auto-subscribes on mount, auto-unsubscribes on unmount.
49
+ * Composed hosts get pose tracking for free.
50
+ * - `reset()` zeros the accumulated yaw / pitch / roll between
51
+ * captures. `useFrameProcessorDriver` calls this on `start()` to
52
+ * preserve pre-v0.11.0 per-capture pose-reset behaviour;
53
+ * composed hosts should call it at the start of each capture too
54
+ * (otherwise pose drifts across captures).
55
+ *
56
+ * ## Behaviour delta from pre-v0.11.0
57
+ *
58
+ * Before: `useFrameProcessorDriver.start()` subscribed the gyro;
59
+ * `stop()` unsubscribed. The subscription was tied to the
60
+ * capture lifecycle.
61
+ *
62
+ * After: the gyro is subscribed for the lifetime of this hook
63
+ * (i.e., as long as the component using it is mounted). In the
64
+ * default `<Camera>` integration the hook mounts when the camera
65
+ * screen mounts, so the practical effect is the same; in
66
+ * custom-composed integrations the host controls mount/unmount
67
+ * by mounting/unmounting the component that calls
68
+ * `useStitcherWorklet`. The battery delta is small: gyroscope
69
+ * sampling at 33ms costs ≪1% CPU on every Android/iOS device
70
+ * the lib supports.
71
+ *
72
+ * `pose reset` semantics are preserved via the new explicit
73
+ * `reset()` method. Hosts that previously relied on `start()`
74
+ * to zero pose now call `stitcher.reset()` at the capture start.
75
+ *
76
+ * ## Pose synthesis (verbatim from `useFrameProcessorDriver`)
77
+ *
78
+ * Quaternion: q = q_yaw * q_pitch * q_roll (Tait-Bryan YPR, body
79
+ * frame). Expanded:
80
+ * qx = cy*sp*cr + sy*cp*sr
81
+ * qy = sy*cp*cr - cy*sp*sr
82
+ * qz = cy*cp*sr - sy*sp*cr
83
+ * qw = cy*cp*cr + sy*sp*sr
84
+ *
85
+ * When roll=0 this collapses to the legacy 2-axis form so captures
86
+ * held level produce bit-identical poses to the pre-v0.6 driver
87
+ * (and bit-identical to v0.10.x's `useFrameProcessorDriver`).
88
+ *
89
+ * ## Throttling (verbatim)
90
+ *
91
+ * `evalEveryNFrames` controls how often the worklet calls the
92
+ * plugin. Default 1. Independent of — and stacks on top of —
93
+ * the stitcher's own internal `flowEvalEveryNFrames` in
94
+ * `KeyframeGate.swift`; effective cadence is the product.
95
+ *
96
+ * ## Pairing with `IncrementalStitcher.start`
97
+ *
98
+ * The plugin's per-frame call into `consumeFrameFromPlugin` is
99
+ * gated by `IncrementalStitcher.frameProcessorIngestEnabled`,
100
+ * which is TRUE only when the stitcher was started with
101
+ * `frameSourceMode === 'frameProcessor'`. Hosts MUST call
102
+ * `incrementalStitcher.start({ frameSourceMode: 'frameProcessor',
103
+ * ... })` to actually get frames into the engine — otherwise the
104
+ * worklet runs to completion but the wrapper drops the call.
105
+ * `Camera.tsx` does this wiring automatically when the host opts
106
+ * into the lib's `useFrameProcessorDriver`. Hosts that compose
107
+ * their own worklet via this hook must do the wiring themselves.
108
+ */
109
+
110
+ import { useCallback, useEffect, useState } from 'react';
111
+ import {
112
+ gyroscope,
113
+ setUpdateIntervalForType,
114
+ SensorTypes,
115
+ } from 'react-native-sensors';
116
+ import type { Subscription } from 'rxjs';
117
+ // Reanimated's `useSharedValue` is the documented vision-camera
118
+ // idiom, but it's a heavy peer dep. `react-native-worklets-core`
119
+ // (already a transitive dep via vision-camera v4 on RN 0.84) exposes
120
+ // the same API surface (a `value` getter/setter readable from
121
+ // worklets and the JS thread) and is sufficient for our use.
122
+ import { useSharedValue } from 'react-native-worklets-core';
123
+ import { VisionCameraProxy } from 'react-native-vision-camera';
124
+ import type {
125
+ Frame,
126
+ FrameProcessorPlugin,
127
+ } from 'react-native-vision-camera';
128
+
129
+ import type { StitcherFrame } from './StitcherFrame';
130
+
131
+ /**
132
+ * Frames the lib's stitching worklet accepts. Accepting either a
133
+ * vc `Frame` (what the host's `useFrameProcessor` body sees) or the
134
+ * lib's `StitcherFrame` (what the lib's `useFrameProcessor` body
135
+ * sees) keeps the same `useStitcherWorklet` usable from both kinds
136
+ * of host worklet bodies without a cast on the call site. The
137
+ * worklet only reads `width` / `height`; the rest of the frame
138
+ * object is forwarded verbatim to the native plugin.
139
+ */
140
+ export type StitcherWorkletInput = Frame | StitcherFrame;
141
+
142
+
143
+ export interface UseStitcherWorkletOptions {
144
+ /**
145
+ * Gyro sample interval in ms (~30 Hz default). Drives the JS-
146
+ * thread pose integration loop; not the producer-thread plugin
147
+ * call rate.
148
+ */
149
+ gyroIntervalMs?: number;
150
+
151
+ /**
152
+ * Approximate horizontal FoV of the device camera, used to
153
+ * synthesise `fx` from frame width. Default 65° matches a typical
154
+ * mid-tier smartphone main camera.
155
+ */
156
+ fovHorizDegrees?: number;
157
+
158
+ /**
159
+ * Approximate vertical FoV of the device camera, used to
160
+ * synthesise `fy` from frame height. Default 50° matches a typical
161
+ * 4:3 phone camera in landscape; for 16:9 portrait you probably
162
+ * want ~75°.
163
+ */
164
+ fovVertDegrees?: number;
165
+
166
+ /**
167
+ * Evaluate the plugin every Nth producer-thread frame. Default 1
168
+ * (every frame).
169
+ */
170
+ evalEveryNFrames?: number;
171
+ }
172
+
173
+
174
+ export interface StitcherWorkletHandle {
175
+ /**
176
+ * Worklet function: pass a `StitcherFrame` to perform one frame of
177
+ * the lib's first-party stitching (throttle + pose synthesis +
178
+ * native plugin call). Safe to call from inside another
179
+ * `'worklet'`-prefixed function (this is the canonical
180
+ * composition pattern).
181
+ *
182
+ * The returned function reference is stable across re-renders as
183
+ * long as the plugin reference doesn't change (which happens at
184
+ * most once — at the moment the JSI plugin finishes
185
+ * registering). Include `stitcher.call` in your `useFrameProcessor`
186
+ * deps so the host worklet rebuilds when the plugin acquires.
187
+ *
188
+ * Safe to invoke before the plugin is ready: the worklet
189
+ * internally short-circuits (the frame is silently skipped).
190
+ * Hosts that want to display a "stitcher initialising…" UI can
191
+ * read `isReady` to gate their own behaviour.
192
+ */
193
+ call: (frame: StitcherWorkletInput) => void;
194
+
195
+ /**
196
+ * Zero accumulated yaw / pitch / roll. Call at the start of each
197
+ * capture so the pose stream starts from `(0, 0, 0)` instead of
198
+ * carrying drift from the previous capture or from idle time
199
+ * between captures. Idempotent; safe to call from JS.
200
+ */
201
+ reset: () => void;
202
+
203
+ /**
204
+ * `true` once the JSI Frame Processor plugin
205
+ * (`cv_flow_gate_process_frame`) has resolved. Before this flips
206
+ * `true`, `call(frame)` is a no-op (the plugin reference is
207
+ * `null`). Hosts integrating via `useFrameProcessorDriver` use
208
+ * this to decide whether to render the frame-processor at all —
209
+ * the driver returns `null` for `frameProcessor` until ready, so
210
+ * `<Camera>` falls back gracefully.
211
+ */
212
+ isReady: boolean;
213
+ }
214
+
215
+
216
+ export function useStitcherWorklet(
217
+ options: UseStitcherWorkletOptions = {},
218
+ ): StitcherWorkletHandle {
219
+ const {
220
+ gyroIntervalMs = 33,
221
+ fovHorizDegrees = 65,
222
+ fovVertDegrees = 50,
223
+ evalEveryNFrames = 1,
224
+ } = options;
225
+
226
+ // ── Plugin acquisition ──────────────────────────────────────────
227
+ //
228
+ // `initFrameProcessorPlugin` can return `undefined` if called
229
+ // before vision-camera's plugin registry has finished initialising
230
+ // (race observed in F8.1.a). Mount-once useEffect with a 16ms
231
+ // retry until success. Verbatim from `useFrameProcessorDriver`.
232
+ const [plugin, setPlugin] = useState<FrameProcessorPlugin | null>(null);
233
+ useEffect(() => {
234
+ let cancelled = false;
235
+ let timerId: ReturnType<typeof setTimeout> | null = null;
236
+ const tryAcquire = () => {
237
+ if (cancelled) return;
238
+ const p = VisionCameraProxy.initFrameProcessorPlugin(
239
+ 'cv_flow_gate_process_frame',
240
+ {},
241
+ );
242
+ if (p != null) {
243
+ setPlugin(p);
244
+ return;
245
+ }
246
+ timerId = setTimeout(tryAcquire, 16);
247
+ };
248
+ tryAcquire();
249
+ return () => {
250
+ cancelled = true;
251
+ if (timerId != null) clearTimeout(timerId);
252
+ };
253
+ // eslint-disable-next-line react-hooks/exhaustive-deps
254
+ }, []);
255
+
256
+ // ── Shared values (worklet ↔ JS thread) ─────────────────────────
257
+ const sharedYaw = useSharedValue(0);
258
+ const sharedPitch = useSharedValue(0);
259
+ const sharedRoll = useSharedValue(0);
260
+ const sharedFrameCounter = useSharedValue(0);
261
+ const sharedEvalEveryN = useSharedValue(Math.max(1, evalEveryNFrames));
262
+ const sharedFxNumerator = useSharedValue(
263
+ 1.0 / (2.0 * Math.tan((fovHorizDegrees * Math.PI / 180) / 2)),
264
+ );
265
+ const sharedFyNumerator = useSharedValue(
266
+ 1.0 / (2.0 * Math.tan((fovVertDegrees * Math.PI / 180) / 2)),
267
+ );
268
+
269
+ // Prop-derived shared values stay in sync via cheap effects.
270
+ useEffect(() => {
271
+ sharedEvalEveryN.value = Math.max(1, evalEveryNFrames);
272
+ }, [evalEveryNFrames, sharedEvalEveryN]);
273
+ useEffect(() => {
274
+ sharedFxNumerator.value =
275
+ 1.0 / (2.0 * Math.tan((fovHorizDegrees * Math.PI / 180) / 2));
276
+ }, [fovHorizDegrees, sharedFxNumerator]);
277
+ useEffect(() => {
278
+ sharedFyNumerator.value =
279
+ 1.0 / (2.0 * Math.tan((fovVertDegrees * Math.PI / 180) / 2));
280
+ }, [fovVertDegrees, sharedFyNumerator]);
281
+
282
+ // ── Gyro subscription (always-on while mounted) ─────────────────
283
+ //
284
+ // v0.11.0 — moved here from `useFrameProcessorDriver.start()`.
285
+ // The composition pattern needs gyro running whenever
286
+ // `useStitcherWorklet` is in use; gating the subscription on a
287
+ // separate start/stop pair would force every composed host to
288
+ // wire its own lifecycle. Cost is tiny: ≪1% CPU at 33ms
289
+ // sampling. See module header "Behaviour delta from pre-v0.11.0".
290
+ useEffect(() => {
291
+ let lastGyroAt: number | null = null;
292
+ setUpdateIntervalForType(SensorTypes.gyroscope, gyroIntervalMs);
293
+ const sub: Subscription = gyroscope.subscribe({
294
+ next: ({ x, y, z }) => {
295
+ const now = Date.now();
296
+ if (lastGyroAt === null) {
297
+ lastGyroAt = now;
298
+ return;
299
+ }
300
+ const dt = (now - lastGyroAt) / 1000.0;
301
+ lastGyroAt = now;
302
+ sharedYaw.value += y * dt;
303
+ sharedPitch.value += x * dt;
304
+ sharedRoll.value += z * dt;
305
+ },
306
+ error: (err) => {
307
+ // eslint-disable-next-line no-console
308
+ console.warn('[useStitcherWorklet] gyro error', err);
309
+ },
310
+ });
311
+ return () => {
312
+ sub.unsubscribe();
313
+ };
314
+ }, [gyroIntervalMs, sharedYaw, sharedPitch, sharedRoll]);
315
+
316
+ // ── Explicit reset (for per-capture pose zero-ing) ──────────────
317
+ const reset = useCallback(() => {
318
+ sharedYaw.value = 0;
319
+ sharedPitch.value = 0;
320
+ sharedRoll.value = 0;
321
+ sharedFrameCounter.value = 0;
322
+ }, [sharedYaw, sharedPitch, sharedRoll, sharedFrameCounter]);
323
+
324
+ // ── Worklet body ────────────────────────────────────────────────
325
+ //
326
+ // Returned as `handle.call`. Re-created when `plugin` changes
327
+ // (which happens at most once at acquire time); deps array on the
328
+ // useCallback ensures consumers' `useFrameProcessor([handle.call])`
329
+ // re-binds when the worklet identity changes.
330
+ //
331
+ // The `'worklet'` directive marks this function for the
332
+ // worklets-core transformer so it can be serialised into the
333
+ // producer-thread runtime; that's the contract that lets a host
334
+ // `useFrameProcessor` worklet body call it without a thread hop.
335
+ const call = useCallback((frame: StitcherWorkletInput) => {
336
+ 'worklet';
337
+ if (plugin == null) return;
338
+
339
+ // Throttle (verbatim from useFrameProcessorDriver).
340
+ sharedFrameCounter.value += 1;
341
+ const N = sharedEvalEveryN.value;
342
+ if (N > 1 && (sharedFrameCounter.value % N) !== 0) return;
343
+
344
+ // Pose synthesis (verbatim from useFrameProcessorDriver).
345
+ const halfYaw = sharedYaw.value / 2;
346
+ const halfPitch = sharedPitch.value / 2;
347
+ const halfRoll = sharedRoll.value / 2;
348
+ const cy_ = Math.cos(halfYaw);
349
+ const sy_ = Math.sin(halfYaw);
350
+ const cp = Math.cos(halfPitch);
351
+ const sp = Math.sin(halfPitch);
352
+ const cr = Math.cos(halfRoll);
353
+ const sr = Math.sin(halfRoll);
354
+ const qx = cy_ * sp * cr + sy_ * cp * sr;
355
+ const qy = sy_ * cp * cr - cy_ * sp * sr;
356
+ const qz = cy_ * cp * sr - sy_ * sp * cr;
357
+ const qw = cy_ * cp * cr + sy_ * sp * sr;
358
+
359
+ // Intrinsics from FoV + actual frame dims.
360
+ const w = frame.width;
361
+ const h = frame.height;
362
+ const fx = w * sharedFxNumerator.value;
363
+ const fy = h * sharedFyNumerator.value;
364
+
365
+ // vc's `plugin.call` is typed against vc's `Frame`. The worklet
366
+ // accepts the union (`Frame | StitcherFrame`); cast through
367
+ // `unknown` because the union doesn't satisfy vc's interface
368
+ // even though structurally both members do.
369
+ plugin.call(frame as unknown as Frame, {
370
+ tx: 0, ty: 0, tz: 0,
371
+ qx, qy, qz, qw,
372
+ fx, fy,
373
+ cx: w / 2, cy: h / 2,
374
+ imageWidth: w, imageHeight: h,
375
+ timestampMs: 0,
376
+ trackingStateRaw: 2, // RNSARTrackingState.tracking (no AR signal in non-AR mode)
377
+ });
378
+ }, [
379
+ plugin,
380
+ sharedFrameCounter,
381
+ sharedEvalEveryN,
382
+ sharedYaw,
383
+ sharedPitch,
384
+ sharedRoll,
385
+ sharedFxNumerator,
386
+ sharedFyNumerator,
387
+ ]);
388
+
389
+ return { call, reset, isReady: plugin != null };
390
+ }