react-native-image-stitcher 0.4.1 → 0.5.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,407 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * useFrameProcessorDriver — vision-camera Frame Processor + gyro
4
+ * driver for the incremental panorama engine. Replaces
5
+ * `useIncrementalJSDriver` in non-AR captures.
6
+ *
7
+ * Why this exists (vs the JS-driver predecessor)
8
+ *
9
+ * The JS driver takes a JPEG snapshot every ~250 ms and feeds the
10
+ * path to `IncrementalStitcher.processFrameAtPath`. That path
11
+ * has three costs:
12
+ *
13
+ * 1. JPEG encode (`takeSnapshot` ≈ 30–80 ms on iPhone 16 Pro)
14
+ * 2. Disk write of the JPEG
15
+ * 3. JPEG decode + cv::Mat alloc inside the engine
16
+ *
17
+ * Per-frame round-trip ~80 ms means ~4 Hz max throughput, and
18
+ * ~80 ms latency between "this is the moment to accept" and "this
19
+ * frame is in the engine". Both numbers caused operator-felt lag
20
+ * on long shelf pans.
21
+ *
22
+ * This hook uses vision-camera's Frame Processor instead. The
23
+ * worklet runs on the camera producer thread at the native frame
24
+ * rate (30 fps on iOS). Each frame goes through a JSI plugin
25
+ * (`cv_flow_gate_process_frame`) directly into
26
+ * `IncrementalStitcher.consumeFrame` — the SAME entry point AR
27
+ * mode uses, with the engine's existing KeyframeGate making the
28
+ * accept/reject decision. Rejected frames cost ~3–8 ms; accepted
29
+ * frames take the same deep-copy + workQueue path AR mode takes.
30
+ *
31
+ * Net wins: no JPEG round-trip on rejected frames, no disk thrash
32
+ * during recording, lower latency to accept, full 30 fps gate
33
+ * evaluation budget.
34
+ *
35
+ * Pose synthesis
36
+ *
37
+ * Non-AR mode has no ARKit pose. We integrate the gyroscope on
38
+ * the JS thread (`react-native-sensors`), accumulate yaw + pitch,
39
+ * and publish them via Reanimated `useSharedValue` so the worklet
40
+ * can read them WITHOUT a thread hop. Translation is reported as
41
+ * zero (no IMU translation; this is a known limitation we share
42
+ * with the legacy driver — drift ~1–2°/min over a 30 s capture is
43
+ * below the gate's overlap threshold and rarely matters).
44
+ *
45
+ * Quaternion synthesis (q = q_yaw * q_pitch * q_roll, Tait-Bryan
46
+ * YPR order to match the legacy driver's body-frame intent):
47
+ * q_yaw = (0, sin(yaw/2), 0, cos(yaw/2))
48
+ * q_pitch = (sin(pitch/2), 0, 0, cos(pitch/2))
49
+ * q_roll = (0, 0, sin(roll/2), cos(roll/2))
50
+ *
51
+ * Expanded (cy, sy = cos/sin(yaw/2); analogous for cp/sp, cr/sr):
52
+ * qx = cy*sp*cr + sy*cp*sr
53
+ * qy = sy*cp*cr - cy*sp*sr
54
+ * qz = cy*cp*sr - sy*sp*cr
55
+ * qw = cy*cp*cr + sy*sp*sr
56
+ *
57
+ * When roll=0 this collapses to the 2-axis form
58
+ * `(cy*sp, sy*cp, -sy*sp, cy*cp)` the legacy driver used, so
59
+ * captures held perfectly level produce identical poses to the
60
+ * pre-roll behaviour.
61
+ *
62
+ * Intrinsics are synthesised from the actual frame dimensions
63
+ * (`frame.width`, `frame.height`) plus the host-provided
64
+ * horizontal/vertical FoV defaults. The stitcher derives its FoV-
65
+ * overlap window from these, so the assumed FoV matters for the
66
+ * gate's overlap math but not for the panorama itself (the
67
+ * stitcher feature-matches + RANSACs the final alignment).
68
+ *
69
+ * Throttling
70
+ *
71
+ * `evalEveryNFrames` controls how often the worklet calls the
72
+ * plugin. Default 1 (every frame). Set higher to amortise the
73
+ * plugin call + consumeFrame's gate evaluation across multiple
74
+ * producer-thread frames on lower-end devices. Independent of —
75
+ * and stacks on top of — the stitcher's own internal
76
+ * `flowEvalEveryNFrames` (see `KeyframeGate.swift`); both
77
+ * throttles can be active simultaneously and the effective cadence
78
+ * is `evalEveryNFrames * flowEvalEveryNFrames`.
79
+ *
80
+ * Lifecycle
81
+ *
82
+ * `start()` subscribes to the gyro and resets pose accumulators.
83
+ * `stop()` unsubscribes and resets. The returned `frameProcessor`
84
+ * is meant to be passed to `<Camera frameProcessor={...} />` —
85
+ * it's stable as long as the plugin reference and the FoV props
86
+ * haven't changed. Returns `null` when the plugin isn't loaded
87
+ * yet; pass `null`-or-fallback to the Camera in that case.
88
+ *
89
+ * Pairing with `IncrementalStitcher.start({frameSourceMode})`
90
+ *
91
+ * The plugin's per-frame call into `consumeFrameFromPlugin` is
92
+ * gated by `IncrementalStitcher.frameProcessorIngestEnabled`,
93
+ * which is TRUE only when the stitcher was started with
94
+ * `frameSourceMode === 'frameProcessor'`. Hosts MUST call
95
+ * `incrementalStitcher.start({ frameSourceMode: 'frameProcessor',
96
+ * ... })` to actually get frames into the engine — otherwise the
97
+ * worklet runs to completion but the wrapper drops the call.
98
+ * `Camera.tsx` does this wiring automatically when the host opts
99
+ * into this driver.
100
+ */
101
+
102
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
103
+ import {
104
+ gyroscope,
105
+ setUpdateIntervalForType,
106
+ SensorTypes,
107
+ } from 'react-native-sensors';
108
+ import type { Subscription } from 'rxjs';
109
+ // Reanimated's `useSharedValue` is the documented vision-camera
110
+ // idiom, but it's a heavy peer dep. `react-native-worklets-core`
111
+ // (already a transitive dep via vision-camera v4 on RN 0.84) exposes
112
+ // the same API surface (a `value` getter/setter readable from
113
+ // worklets and the JS thread) and is sufficient for our use.
114
+ import { useSharedValue } from 'react-native-worklets-core';
115
+ import {
116
+ useFrameProcessor,
117
+ VisionCameraProxy,
118
+ } from 'react-native-vision-camera';
119
+ import type {
120
+ FrameProcessorPlugin,
121
+ ReadonlyFrameProcessor,
122
+ } from 'react-native-vision-camera';
123
+
124
+
125
+ export interface UseFrameProcessorDriverOptions {
126
+ /**
127
+ * Gyro sample interval in ms (~30 Hz default). Drives the JS-
128
+ * thread pose integration loop; not the producer-thread plugin
129
+ * call rate (the plugin runs at vision-camera's frame rate,
130
+ * usually 30 fps).
131
+ */
132
+ gyroIntervalMs?: number;
133
+
134
+ /**
135
+ * Approximate horizontal FoV of the device camera, used to
136
+ * synthesise `fx` from frame width. Default 65° matches a typical
137
+ * mid-tier smartphone main camera. Host apps that know the actual
138
+ * FoV (e.g. via `Camera.getCameraFormat`) should pass it here —
139
+ * the engine's overlap gate gets a slightly better estimate.
140
+ */
141
+ fovHorizDegrees?: number;
142
+
143
+ /**
144
+ * Approximate vertical FoV of the device camera, used to
145
+ * synthesise `fy` from frame height. Default 50° matches a
146
+ * typical 4:3 phone camera in landscape; for 16:9 portrait you
147
+ * probably want ~75°.
148
+ */
149
+ fovVertDegrees?: number;
150
+
151
+ /**
152
+ * Evaluate the plugin every Nth producer-thread frame. Default 1
153
+ * (every frame). Higher values reduce the producer-thread cost
154
+ * linearly at the price of acceptance latency — N=3 with 30 fps
155
+ * source = up to 100 ms before a key moment is evaluated.
156
+ */
157
+ evalEveryNFrames?: number;
158
+ }
159
+
160
+
161
+ export interface FrameProcessorDriverHandle {
162
+ /** Subscribe to the gyro + reset pose accumulators. Idempotent. */
163
+ start: () => void;
164
+ /** Unsubscribe + reset pose. */
165
+ stop: () => void;
166
+ /**
167
+ * Pass this to `<Camera frameProcessor={...} />`. `null` until
168
+ * the JSI plugin is loaded (typically resolves within ~1 frame of
169
+ * mount); the consumer should fall back to undefined / a no-op
170
+ * processor in that window.
171
+ */
172
+ frameProcessor: ReadonlyFrameProcessor | null;
173
+ /** Whether `start()` has been called and `stop()` hasn't. */
174
+ isRunning: boolean;
175
+ }
176
+
177
+
178
+ export function useFrameProcessorDriver(
179
+ options: UseFrameProcessorDriverOptions = {},
180
+ ): FrameProcessorDriverHandle {
181
+ const {
182
+ gyroIntervalMs = 33,
183
+ fovHorizDegrees = 65,
184
+ fovVertDegrees = 50,
185
+ evalEveryNFrames = 1,
186
+ } = options;
187
+
188
+ // ── Plugin acquisition ──────────────────────────────────────────
189
+ //
190
+ // `initFrameProcessorPlugin` can return `undefined` if called
191
+ // before vision-camera's plugin registry has finished initialising
192
+ // (race observed in F8.1.a). We retry on a fixed timer instead of
193
+ // firing on every render — the earlier render-driven pattern
194
+ // (adversarial-review H3) re-invoked `initFrameProcessorPlugin`
195
+ // 60+ times per second during recording, and the vision-camera
196
+ // contract for repeated lookups is undocumented.
197
+ //
198
+ // Pattern: mount-once useEffect, try synchronously, fall back to a
199
+ // 16-ms retry timer until success or unmount.
200
+ const [plugin, setPlugin] = useState<FrameProcessorPlugin | null>(null);
201
+ useEffect(() => {
202
+ let cancelled = false;
203
+ let timerId: ReturnType<typeof setTimeout> | null = null;
204
+ const tryAcquire = () => {
205
+ if (cancelled) return;
206
+ const p = VisionCameraProxy.initFrameProcessorPlugin(
207
+ 'cv_flow_gate_process_frame',
208
+ {},
209
+ );
210
+ if (p != null) {
211
+ setPlugin(p);
212
+ return;
213
+ }
214
+ // ~one display-frame retry — matches the F8.1.a observation
215
+ // that the registry becomes ready by the next render tick.
216
+ timerId = setTimeout(tryAcquire, 16);
217
+ };
218
+ tryAcquire();
219
+ return () => {
220
+ cancelled = true;
221
+ if (timerId != null) clearTimeout(timerId);
222
+ };
223
+ // Empty deps on purpose — runs ONCE on mount. Re-acquiring on
224
+ // re-render would race with worklet binding.
225
+ // eslint-disable-next-line react-hooks/exhaustive-deps
226
+ }, []);
227
+
228
+ // ── Shared values (worklet ↔ JS thread) ─────────────────────────
229
+ //
230
+ // Reanimated guarantees coherent reads from the producer thread.
231
+ // We write yaw/pitch on the JS thread (gyro callbacks); the worklet
232
+ // reads them every frame. No round-trip cost — these are mapped
233
+ // into the worklet's runtime by the Reanimated bridge.
234
+ //
235
+ // FoV-derived values (the "half-angle tangent reciprocal"
236
+ // f-numerators) are pre-computed on the JS thread + published via
237
+ // shared values so the worklet's dependency array shrinks to just
238
+ // `[plugin]`. Earlier draft baked `fovHorizDegrees` /
239
+ // `fovVertDegrees` into the closure → worklet re-serialised on
240
+ // every host re-render that changed the prop refs (adversarial-
241
+ // review M1).
242
+ const sharedYaw = useSharedValue(0);
243
+ const sharedPitch = useSharedValue(0);
244
+ // F8.3-followup-roll — integrate gyroscope Z (out-of-screen for a
245
+ // portrait device) to track wrist-twist roll. Field captures with
246
+ // casual hand-hold rarely stay perfectly level; without this the
247
+ // pose stream lies and the cv::Stitcher's intrinsic estimator may
248
+ // pick a worse projection mode.
249
+ const sharedRoll = useSharedValue(0);
250
+ const sharedFrameCounter = useSharedValue(0);
251
+ const sharedEvalEveryN = useSharedValue(Math.max(1, evalEveryNFrames));
252
+ const sharedFxNumerator = useSharedValue(
253
+ 1.0 / (2.0 * Math.tan((fovHorizDegrees * Math.PI / 180) / 2)),
254
+ );
255
+ const sharedFyNumerator = useSharedValue(
256
+ 1.0 / (2.0 * Math.tan((fovVertDegrees * Math.PI / 180) / 2)),
257
+ );
258
+
259
+ // Keep prop-derived shared values in sync. Cheap re-renders;
260
+ // these don't trigger worklet rebuild.
261
+ useEffect(() => {
262
+ sharedEvalEveryN.value = Math.max(1, evalEveryNFrames);
263
+ }, [evalEveryNFrames, sharedEvalEveryN]);
264
+ useEffect(() => {
265
+ sharedFxNumerator.value =
266
+ 1.0 / (2.0 * Math.tan((fovHorizDegrees * Math.PI / 180) / 2));
267
+ }, [fovHorizDegrees, sharedFxNumerator]);
268
+ useEffect(() => {
269
+ sharedFyNumerator.value =
270
+ 1.0 / (2.0 * Math.tan((fovVertDegrees * Math.PI / 180) / 2));
271
+ }, [fovVertDegrees, sharedFyNumerator]);
272
+
273
+ // ── Lifecycle state (JS thread only) ────────────────────────────
274
+ const gyroSubRef = useRef<Subscription | null>(null);
275
+ const lastGyroAtRef = useRef<number | null>(null);
276
+ const isRunningRef = useRef(false);
277
+
278
+ const stop = useCallback(() => {
279
+ if (gyroSubRef.current) {
280
+ gyroSubRef.current.unsubscribe();
281
+ gyroSubRef.current = null;
282
+ }
283
+ isRunningRef.current = false;
284
+ sharedYaw.value = 0;
285
+ sharedPitch.value = 0;
286
+ sharedRoll.value = 0;
287
+ sharedFrameCounter.value = 0;
288
+ lastGyroAtRef.current = null;
289
+ }, [sharedYaw, sharedPitch, sharedRoll, sharedFrameCounter]);
290
+
291
+ const start = useCallback(() => {
292
+ if (isRunningRef.current) return;
293
+ sharedYaw.value = 0;
294
+ sharedPitch.value = 0;
295
+ sharedRoll.value = 0;
296
+ sharedFrameCounter.value = 0;
297
+ lastGyroAtRef.current = null;
298
+ isRunningRef.current = true;
299
+
300
+ // Gyro integration. Each sample carries angular velocity in
301
+ // rad/s; multiply by dt to accumulate displacement. Axes for a
302
+ // device held portrait:
303
+ // y = horizontal pan (yaw, about world-Y)
304
+ // x = vertical tilt (pitch, about world-X)
305
+ // z = wrist-twist roll (about world-Z, normal to the screen)
306
+ // Signs match the legacy `useIncrementalJSDriver` for x/y; z
307
+ // follows the same right-hand-rule convention. If field
308
+ // captures show inverted roll, flip the sign on `z * dt` below.
309
+ setUpdateIntervalForType(SensorTypes.gyroscope, gyroIntervalMs);
310
+ gyroSubRef.current = gyroscope.subscribe({
311
+ next: ({ x, y, z }) => {
312
+ const now = Date.now();
313
+ if (lastGyroAtRef.current === null) {
314
+ lastGyroAtRef.current = now;
315
+ return;
316
+ }
317
+ const dt = (now - lastGyroAtRef.current) / 1000.0;
318
+ lastGyroAtRef.current = now;
319
+ sharedYaw.value += y * dt;
320
+ sharedPitch.value += x * dt;
321
+ sharedRoll.value += z * dt;
322
+ },
323
+ error: (err) => {
324
+ // eslint-disable-next-line no-console
325
+ console.warn('[useFrameProcessorDriver] gyro error', err);
326
+ },
327
+ });
328
+ }, [gyroIntervalMs, sharedYaw, sharedPitch, sharedRoll, sharedFrameCounter]);
329
+
330
+ // ── Worklet ─────────────────────────────────────────────────────
331
+ //
332
+ // Memoised: rebuilt only when the plugin acquires (null → defined)
333
+ // or when the FoV props change (cheap math but they're in the
334
+ // closure so they must be in the deps). Shared values are NOT in
335
+ // the deps — Reanimated wires their .value reads through the
336
+ // worklet's frozen runtime independently of React's render cycle.
337
+ const frameProcessor = useFrameProcessor((frame) => {
338
+ 'worklet';
339
+ if (plugin == null) return;
340
+
341
+ // Throttle: only every Nth frame. Counter increments first so
342
+ // frame #0 is "due" (N>=1 always divides 0). Cheaper than
343
+ // calling the plugin on rejected frames; saves the ~1 µs
344
+ // marshalling cost per skip.
345
+ sharedFrameCounter.value += 1;
346
+ const N = sharedEvalEveryN.value;
347
+ if (N > 1 && (sharedFrameCounter.value % N) !== 0) return;
348
+
349
+ // Synthesise quaternion from accumulated yaw + pitch + roll.
350
+ // YPR Tait-Bryan order: q = q_yaw * q_pitch * q_roll. When
351
+ // roll=0 this reduces to the legacy 2-axis form (cy*sp, sy*cp,
352
+ // -sy*sp, cy*cp), so captures held level produce identical
353
+ // poses to the pre-F8.3-followup-roll behaviour. See the
354
+ // expanded math in the file header doc-comment.
355
+ const halfYaw = sharedYaw.value / 2;
356
+ const halfPitch = sharedPitch.value / 2;
357
+ const halfRoll = sharedRoll.value / 2;
358
+ const cy_ = Math.cos(halfYaw);
359
+ const sy_ = Math.sin(halfYaw);
360
+ const cp = Math.cos(halfPitch);
361
+ const sp = Math.sin(halfPitch);
362
+ const cr = Math.cos(halfRoll);
363
+ const sr = Math.sin(halfRoll);
364
+ const qx = cy_ * sp * cr + sy_ * cp * sr;
365
+ const qy = sy_ * cp * cr - cy_ * sp * sr;
366
+ const qz = cy_ * cp * sr - sy_ * sp * cr;
367
+ const qw = cy_ * cp * cr + sy_ * sp * sr;
368
+
369
+ // Intrinsics from FoV + actual frame dims.
370
+ // fx = w * (1 / (2 * tan(fovH/2))) (the parenthesised half
371
+ // is the precomputed `sharedFxNumerator` — see M1 fix).
372
+ const w = frame.width;
373
+ const h = frame.height;
374
+ const fx = w * sharedFxNumerator.value;
375
+ const fy = h * sharedFyNumerator.value;
376
+
377
+ plugin.call(frame, {
378
+ tx: 0, ty: 0, tz: 0,
379
+ qx, qy, qz, qw,
380
+ fx, fy,
381
+ cx: w / 2, cy: h / 2,
382
+ imageWidth: w, imageHeight: h,
383
+ timestampMs: 0,
384
+ // 2 == RNSARTrackingState.tracking — we always claim "good
385
+ // tracking" because there's no ARKit signal to differentiate
386
+ // (matches legacy useIncrementalJSDriver semantics).
387
+ trackingStateRaw: 2,
388
+ });
389
+ // Deps array intentionally minimal: only `plugin` actually
390
+ // requires worklet rebuild. All FoV / pose / counter / cadence
391
+ // values flow through stable shared-value refs that Reanimated
392
+ // wires through the producer-thread runtime independently of
393
+ // React's render cycle. (Adversarial-review M1.)
394
+ }, [plugin]);
395
+
396
+ // ── Return handle ───────────────────────────────────────────────
397
+ //
398
+ // Returns a getter for `isRunning` so callers always see the live
399
+ // state (the hook itself doesn't re-render on start/stop — that's
400
+ // intentional, avoids stale-Camera-prop churn).
401
+ return useMemo<FrameProcessorDriverHandle>(() => ({
402
+ start,
403
+ stop,
404
+ frameProcessor: plugin != null ? frameProcessor : null,
405
+ get isRunning() { return isRunningRef.current; },
406
+ }), [start, stop, plugin, frameProcessor]);
407
+ }
@@ -49,6 +49,12 @@ import {
49
49
  import type { Subscription } from 'rxjs';
50
50
  import type { Camera } from 'react-native-vision-camera';
51
51
 
52
+ // One-shot deprecation flag — module-scoped so multiple host
53
+ // instances of the hook all share the same gate and we only emit
54
+ // the warning the first time anyone calls .start() in this
55
+ // process.
56
+ let deprecationWarningEmitted = false;
57
+
52
58
 
53
59
  export interface UseIncrementalJSDriverOptions {
54
60
  /**
@@ -159,6 +165,24 @@ export function useIncrementalJSDriver(
159
165
  // Swift bridge), so the same driver feeds both platforms in
160
166
  // non-AR mode.
161
167
  if (isRunningRef.current) return;
168
+ // F8.5 — one-shot deprecation warning. v0.5.0 introduced
169
+ // `useFrameProcessorDriver` (vision-camera producer-thread
170
+ // path, native frame rate, no JPEG round-trip). The legacy
171
+ // takeSnapshot path stays available for one minor cycle to
172
+ // give hosts time to migrate, then is removed in v0.6.
173
+ if (!deprecationWarningEmitted) {
174
+ deprecationWarningEmitted = true;
175
+ // eslint-disable-next-line no-console
176
+ console.warn(
177
+ '[react-native-image-stitcher] `useIncrementalJSDriver` '
178
+ + 'is DEPRECATED as of v0.5.0 and will be REMOVED in '
179
+ + 'v0.6.0. Migrate to `useFrameProcessorDriver` (or '
180
+ + 'simply let `<Camera>` use its default driver — no host '
181
+ + 'code change needed). Opt-out via the `legacyDriver` '
182
+ + 'prop on `<Camera>` if you need to stay on the legacy '
183
+ + 'path temporarily.',
184
+ );
185
+ }
162
186
  const native = getNativeIncremental();
163
187
  if (!native) return;
164
188