react-native-image-stitcher 0.9.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +246 -0
  2. package/android/build.gradle +10 -0
  3. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +115 -10
  4. package/cpp/stitcher_worklet_registry.cpp +10 -0
  5. package/cpp/stitcher_worklet_registry.hpp +10 -0
  6. package/cpp/tests/CMakeLists.txt +98 -0
  7. package/cpp/tests/README.md +86 -0
  8. package/cpp/tests/pose_test.cpp +74 -0
  9. package/cpp/tests/stitcher_frame_data_test.cpp +132 -0
  10. package/cpp/tests/stitcher_worklet_registry_test.cpp +195 -0
  11. package/cpp/tests/stubs/jsi/jsi.h +33 -0
  12. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +34 -0
  13. package/dist/camera/Camera.d.ts +30 -14
  14. package/dist/camera/Camera.js +18 -18
  15. package/dist/camera/useCapture.d.ts +1 -1
  16. package/dist/camera/useCapture.js +1 -1
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.js +9 -1
  19. package/dist/stitching/incremental.d.ts +41 -0
  20. package/dist/stitching/useFrameProcessorDriver.d.ts +50 -95
  21. package/dist/stitching/useFrameProcessorDriver.js +76 -294
  22. package/dist/stitching/useFrameStream.js +52 -37
  23. package/dist/stitching/useStitcherWorklet.d.ts +185 -0
  24. package/dist/stitching/useStitcherWorklet.js +275 -0
  25. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +138 -9
  26. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +50 -14
  27. package/package.json +1 -1
  28. package/src/camera/Camera.tsx +48 -32
  29. package/src/camera/useCapture.ts +1 -1
  30. package/src/index.ts +13 -0
  31. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +276 -0
  32. package/src/stitching/incremental.ts +42 -0
  33. package/src/stitching/useFrameProcessorDriver.ts +79 -320
  34. package/src/stitching/useFrameStream.ts +55 -39
  35. package/src/stitching/useStitcherWorklet.ts +390 -0
@@ -5,123 +5,66 @@
5
5
  * from v0.6 onward (replaced the deprecated `useIncrementalJSDriver`
6
6
  * hook, which was removed in v0.6).
7
7
  *
8
- * Why this exists (vs the pre-v0.6 JS-driver predecessor)
9
- *
10
- * The old JS driver took a JPEG snapshot every ~250 ms and fed the
11
- * path to `IncrementalStitcher.processFrameAtPath` (both removed in
12
- * v0.6). That path had three costs:
13
- *
14
- * 1. JPEG encode (`takeSnapshot` ≈ 30–80 ms on iPhone 16 Pro)
15
- * 2. Disk write of the JPEG
16
- * 3. JPEG decode + cv::Mat alloc inside the engine
17
- *
18
- * Per-frame round-trip ~80 ms means ~4 Hz max throughput, and
19
- * ~80 ms latency between "this is the moment to accept" and "this
20
- * frame is in the engine". Both numbers caused operator-felt lag
21
- * on long shelf pans.
22
- *
23
- * This hook uses vision-camera's Frame Processor instead. The
24
- * worklet runs on the camera producer thread at the native frame
25
- * rate (30 fps on iOS). Each frame goes through a JSI plugin
26
- * (`cv_flow_gate_process_frame`) directly into
27
- * `IncrementalStitcher.consumeFrame` — the SAME entry point AR
28
- * mode uses, with the engine's existing KeyframeGate making the
29
- * accept/reject decision. Rejected frames cost ~3–8 ms; accepted
30
- * frames take the same deep-copy + workQueue path AR mode takes.
31
- *
32
- * Net wins: no JPEG round-trip on rejected frames, no disk thrash
33
- * during recording, lower latency to accept, full 30 fps gate
34
- * evaluation budget.
35
- *
36
- * Pose synthesis
37
- *
38
- * Non-AR mode has no ARKit pose. We integrate the gyroscope on
39
- * the JS thread (`react-native-sensors`), accumulate yaw + pitch,
40
- * and publish them via Reanimated `useSharedValue` so the worklet
41
- * can read them WITHOUT a thread hop. Translation is reported as
42
- * zero (no IMU translation; this is a known limitation we share
43
- * with the legacy driver drift ~1–2°/min over a 30 s capture is
44
- * below the gate's overlap threshold and rarely matters).
45
- *
46
- * Quaternion synthesis (q = q_yaw * q_pitch * q_roll, Tait-Bryan
47
- * YPR order to match the legacy driver's body-frame intent):
48
- * q_yaw = (0, sin(yaw/2), 0, cos(yaw/2))
49
- * q_pitch = (sin(pitch/2), 0, 0, cos(pitch/2))
50
- * q_roll = (0, 0, sin(roll/2), cos(roll/2))
51
- *
52
- * Expanded (cy, sy = cos/sin(yaw/2); analogous for cp/sp, cr/sr):
53
- * qx = cy*sp*cr + sy*cp*sr
54
- * qy = sy*cp*cr - cy*sp*sr
55
- * qz = cy*cp*sr - sy*sp*cr
56
- * qw = cy*cp*cr + sy*sp*sr
57
- *
58
- * When roll=0 this collapses to the 2-axis form
59
- * `(cy*sp, sy*cp, -sy*sp, cy*cp)` the legacy driver used, so
60
- * captures held perfectly level produce identical poses to the
61
- * pre-roll behaviour.
62
- *
63
- * Intrinsics are synthesised from the actual frame dimensions
64
- * (`frame.width`, `frame.height`) plus the host-provided
65
- * horizontal/vertical FoV defaults. The stitcher derives its FoV-
66
- * overlap window from these, so the assumed FoV matters for the
67
- * gate's overlap math but not for the panorama itself (the
68
- * stitcher feature-matches + RANSACs the final alignment).
69
- *
70
- * Throttling
71
- *
72
- * `evalEveryNFrames` controls how often the worklet calls the
73
- * plugin. Default 1 (every frame). Set higher to amortise the
74
- * plugin call + consumeFrame's gate evaluation across multiple
75
- * producer-thread frames on lower-end devices. Independent of —
76
- * and stacks on top of — the stitcher's own internal
77
- * `flowEvalEveryNFrames` (see `KeyframeGate.swift`); both
78
- * throttles can be active simultaneously and the effective cadence
79
- * is `evalEveryNFrames * flowEvalEveryNFrames`.
80
- *
81
- * Lifecycle
82
- *
83
- * `start()` subscribes to the gyro and resets pose accumulators.
84
- * `stop()` unsubscribes and resets. The returned `frameProcessor`
85
- * is meant to be passed to `<Camera frameProcessor={...} />` —
86
- * it's stable as long as the plugin reference and the FoV props
87
- * haven't changed. Returns `null` when the plugin isn't loaded
88
- * yet; pass `null`-or-fallback to the Camera in that case.
89
- *
90
- * Pairing with `IncrementalStitcher.start({frameSourceMode})`
91
- *
92
- * The plugin's per-frame call into `consumeFrameFromPlugin` is
93
- * gated by `IncrementalStitcher.frameProcessorIngestEnabled`,
94
- * which is TRUE only when the stitcher was started with
95
- * `frameSourceMode === 'frameProcessor'`. Hosts MUST call
96
- * `incrementalStitcher.start({ frameSourceMode: 'frameProcessor',
97
- * ... })` to actually get frames into the engine — otherwise the
98
- * worklet runs to completion but the wrapper drops the call.
99
- * `Camera.tsx` does this wiring automatically when the host opts
100
- * into this driver.
8
+ * v0.11.0 refactored to a thin wrapper around `useStitcherWorklet`.
9
+ * The plugin acquisition + shared-value declarations + gyro
10
+ * subscription + worklet body all live in `useStitcherWorklet` now;
11
+ * this hook just binds the returned worklet via vision-camera's
12
+ * `useFrameProcessor` and exposes the legacy `start` / `stop` /
13
+ * `isRunning` API for backwards compatibility with v0.10.x.
14
+ *
15
+ * ## Why the v0.11.0 split
16
+ *
17
+ * vision-camera v4 allows ONE frame processor per `<Camera>` mount.
18
+ * Pre-v0.11.0, hosts that wanted to compose their own worklet with
19
+ * the lib's first-party stitching couldn't passing a host
20
+ * `frameProcessor` REPLACED the lib's processor. v0.11.0 closes
21
+ * this gap by exposing the worklet body via `useStitcherWorklet`
22
+ * so hosts can write:
23
+ *
24
+ * const stitcher = useStitcherWorklet();
25
+ * const fp = useFrameProcessor((frame) => {
26
+ * 'worklet';
27
+ * hostPreLogic(frame);
28
+ * stitcher.call(frame); // first-party stitching
29
+ * hostPostLogic(frame);
30
+ * }, [stitcher.call]);
31
+ *
32
+ * `useFrameProcessorDriver` keeps the legacy default-integration
33
+ * shape (start / stop / isRunning) for the `<Camera>` component's
34
+ * built-in non-AR path and for any host still using the v0.10.x API
35
+ * directly. No behavioural change for those callers.
36
+ *
37
+ * ## start / stop behaviour
38
+ *
39
+ * - `start()` calls `stitcher.reset()` to zero the accumulated
40
+ * pose (preserves the pre-v0.11.0 "each capture starts with
41
+ * pose = (0, 0, 0)" contract).
42
+ * - `stop()` also resets the pose (idempotent; matches the
43
+ * pre-v0.11.0 stop() side effect of zeroing yaw / pitch / roll).
44
+ * - The gyro subscription itself is owned by `useStitcherWorklet`
45
+ * and runs for the lifetime of the hook. In the default
46
+ * `<Camera>` integration this means gyro is on while the camera
47
+ * screen is mounted same practical scope as pre-v0.11.0 in
48
+ * all observed host integrations (capture screens mount
49
+ * `<Camera>` for the duration of capture; idle screens don't).
50
+ *
51
+ * ## Pose synthesis / intrinsics / throttling
52
+ *
53
+ * Owned by `useStitcherWorklet`. See that file's header for the
54
+ * quaternion math, FoV-to-intrinsics derivation, throttle gate, and
55
+ * pairing-with-IncrementalStitcher.start docs.
101
56
  */
102
57
 
103
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
104
- import {
105
- gyroscope,
106
- setUpdateIntervalForType,
107
- SensorTypes,
108
- } from 'react-native-sensors';
109
- import type { Subscription } from 'rxjs';
110
- // Reanimated's `useSharedValue` is the documented vision-camera
111
- // idiom, but it's a heavy peer dep. `react-native-worklets-core`
112
- // (already a transitive dep via vision-camera v4 on RN 0.84) exposes
113
- // the same API surface (a `value` getter/setter readable from
114
- // worklets and the JS thread) and is sufficient for our use.
115
- import { useSharedValue } from 'react-native-worklets-core';
58
+ import { useCallback, useMemo, useRef } from 'react';
116
59
  import {
117
60
  useFrameProcessor,
118
- VisionCameraProxy,
119
61
  } from 'react-native-vision-camera';
120
62
  import type {
121
- FrameProcessorPlugin,
122
63
  ReadonlyFrameProcessor,
123
64
  } from 'react-native-vision-camera';
124
65
 
66
+ import { useStitcherWorklet } from './useStitcherWorklet';
67
+
125
68
 
126
69
  export interface UseFrameProcessorDriverOptions {
127
70
  /**
@@ -160,9 +103,9 @@ export interface UseFrameProcessorDriverOptions {
160
103
 
161
104
 
162
105
  export interface FrameProcessorDriverHandle {
163
- /** Subscribe to the gyro + reset pose accumulators. Idempotent. */
106
+ /** Reset pose accumulators + mark the driver as running. Idempotent. */
164
107
  start: () => void;
165
- /** Unsubscribe + reset pose. */
108
+ /** Reset pose + mark the driver as stopped. Idempotent. */
166
109
  stop: () => void;
167
110
  /**
168
111
  * Pass this to `<Camera frameProcessor={...} />`. `null` until
@@ -179,230 +122,46 @@ export interface FrameProcessorDriverHandle {
179
122
  export function useFrameProcessorDriver(
180
123
  options: UseFrameProcessorDriverOptions = {},
181
124
  ): FrameProcessorDriverHandle {
182
- const {
183
- gyroIntervalMs = 33,
184
- fovHorizDegrees = 65,
185
- fovVertDegrees = 50,
186
- evalEveryNFrames = 1,
187
- } = options;
125
+ // v0.11.0 — delegate plugin / shared values / gyro / worklet body
126
+ // to `useStitcherWorklet`. This hook is now a thin wrapper that
127
+ // binds the returned worklet via `useFrameProcessor` and exposes
128
+ // the legacy lifecycle API.
129
+ const stitcher = useStitcherWorklet(options);
188
130
 
189
- // ── Plugin acquisition ──────────────────────────────────────────
190
- //
191
- // `initFrameProcessorPlugin` can return `undefined` if called
192
- // before vision-camera's plugin registry has finished initialising
193
- // (race observed in F8.1.a). We retry on a fixed timer instead of
194
- // firing on every render — the earlier render-driven pattern
195
- // (adversarial-review H3) re-invoked `initFrameProcessorPlugin`
196
- // 60+ times per second during recording, and the vision-camera
197
- // contract for repeated lookups is undocumented.
198
- //
199
- // Pattern: mount-once useEffect, try synchronously, fall back to a
200
- // 16-ms retry timer until success or unmount.
201
- const [plugin, setPlugin] = useState<FrameProcessorPlugin | null>(null);
202
- useEffect(() => {
203
- let cancelled = false;
204
- let timerId: ReturnType<typeof setTimeout> | null = null;
205
- const tryAcquire = () => {
206
- if (cancelled) return;
207
- const p = VisionCameraProxy.initFrameProcessorPlugin(
208
- 'cv_flow_gate_process_frame',
209
- {},
210
- );
211
- if (p != null) {
212
- setPlugin(p);
213
- return;
214
- }
215
- // ~one display-frame retry — matches the F8.1.a observation
216
- // that the registry becomes ready by the next render tick.
217
- timerId = setTimeout(tryAcquire, 16);
218
- };
219
- tryAcquire();
220
- return () => {
221
- cancelled = true;
222
- if (timerId != null) clearTimeout(timerId);
223
- };
224
- // Empty deps on purpose — runs ONCE on mount. Re-acquiring on
225
- // re-render would race with worklet binding.
226
- // eslint-disable-next-line react-hooks/exhaustive-deps
227
- }, []);
228
-
229
- // ── Shared values (worklet ↔ JS thread) ─────────────────────────
230
- //
231
- // Reanimated guarantees coherent reads from the producer thread.
232
- // We write yaw/pitch on the JS thread (gyro callbacks); the worklet
233
- // reads them every frame. No round-trip cost — these are mapped
234
- // into the worklet's runtime by the Reanimated bridge.
235
- //
236
- // FoV-derived values (the "half-angle tangent reciprocal"
237
- // f-numerators) are pre-computed on the JS thread + published via
238
- // shared values so the worklet's dependency array shrinks to just
239
- // `[plugin]`. Earlier draft baked `fovHorizDegrees` /
240
- // `fovVertDegrees` into the closure → worklet re-serialised on
241
- // every host re-render that changed the prop refs (adversarial-
242
- // review M1).
243
- const sharedYaw = useSharedValue(0);
244
- const sharedPitch = useSharedValue(0);
245
- // F8.3-followup-roll — integrate gyroscope Z (out-of-screen for a
246
- // portrait device) to track wrist-twist roll. Field captures with
247
- // casual hand-hold rarely stay perfectly level; without this the
248
- // pose stream lies and the cv::Stitcher's intrinsic estimator may
249
- // pick a worse projection mode.
250
- const sharedRoll = useSharedValue(0);
251
- const sharedFrameCounter = useSharedValue(0);
252
- const sharedEvalEveryN = useSharedValue(Math.max(1, evalEveryNFrames));
253
- const sharedFxNumerator = useSharedValue(
254
- 1.0 / (2.0 * Math.tan((fovHorizDegrees * Math.PI / 180) / 2)),
255
- );
256
- const sharedFyNumerator = useSharedValue(
257
- 1.0 / (2.0 * Math.tan((fovVertDegrees * Math.PI / 180) / 2)),
258
- );
259
-
260
- // Keep prop-derived shared values in sync. Cheap re-renders;
261
- // these don't trigger worklet rebuild.
262
- useEffect(() => {
263
- sharedEvalEveryN.value = Math.max(1, evalEveryNFrames);
264
- }, [evalEveryNFrames, sharedEvalEveryN]);
265
- useEffect(() => {
266
- sharedFxNumerator.value =
267
- 1.0 / (2.0 * Math.tan((fovHorizDegrees * Math.PI / 180) / 2));
268
- }, [fovHorizDegrees, sharedFxNumerator]);
269
- useEffect(() => {
270
- sharedFyNumerator.value =
271
- 1.0 / (2.0 * Math.tan((fovVertDegrees * Math.PI / 180) / 2));
272
- }, [fovVertDegrees, sharedFyNumerator]);
273
-
274
- // ── Lifecycle state (JS thread only) ────────────────────────────
275
- const gyroSubRef = useRef<Subscription | null>(null);
276
- const lastGyroAtRef = useRef<number | null>(null);
277
131
  const isRunningRef = useRef(false);
278
132
 
279
- const stop = useCallback(() => {
280
- if (gyroSubRef.current) {
281
- gyroSubRef.current.unsubscribe();
282
- gyroSubRef.current = null;
283
- }
284
- isRunningRef.current = false;
285
- sharedYaw.value = 0;
286
- sharedPitch.value = 0;
287
- sharedRoll.value = 0;
288
- sharedFrameCounter.value = 0;
289
- lastGyroAtRef.current = null;
290
- }, [sharedYaw, sharedPitch, sharedRoll, sharedFrameCounter]);
291
-
292
133
  const start = useCallback(() => {
293
134
  if (isRunningRef.current) return;
294
- sharedYaw.value = 0;
295
- sharedPitch.value = 0;
296
- sharedRoll.value = 0;
297
- sharedFrameCounter.value = 0;
298
- lastGyroAtRef.current = null;
135
+ stitcher.reset();
299
136
  isRunningRef.current = true;
137
+ }, [stitcher]);
300
138
 
301
- // Gyro integration. Each sample carries angular velocity in
302
- // rad/s; multiply by dt to accumulate displacement. Axes for a
303
- // device held portrait:
304
- // y = horizontal pan (yaw, about world-Y)
305
- // x = vertical tilt (pitch, about world-X)
306
- // z = wrist-twist roll (about world-Z, normal to the screen)
307
- // Right-hand-rule convention throughout — same signs the pre-v0.6
308
- // `useIncrementalJSDriver` produced. If field captures show
309
- // inverted roll, flip the sign on `z * dt` below.
310
- setUpdateIntervalForType(SensorTypes.gyroscope, gyroIntervalMs);
311
- gyroSubRef.current = gyroscope.subscribe({
312
- next: ({ x, y, z }) => {
313
- const now = Date.now();
314
- if (lastGyroAtRef.current === null) {
315
- lastGyroAtRef.current = now;
316
- return;
317
- }
318
- const dt = (now - lastGyroAtRef.current) / 1000.0;
319
- lastGyroAtRef.current = now;
320
- sharedYaw.value += y * dt;
321
- sharedPitch.value += x * dt;
322
- sharedRoll.value += z * dt;
323
- },
324
- error: (err) => {
325
- // eslint-disable-next-line no-console
326
- console.warn('[useFrameProcessorDriver] gyro error', err);
327
- },
328
- });
329
- }, [gyroIntervalMs, sharedYaw, sharedPitch, sharedRoll, sharedFrameCounter]);
139
+ const stop = useCallback(() => {
140
+ if (!isRunningRef.current) return;
141
+ stitcher.reset();
142
+ isRunningRef.current = false;
143
+ }, [stitcher]);
330
144
 
331
- // ── Worklet ─────────────────────────────────────────────────────
145
+ // ── Worklet binding ─────────────────────────────────────────────
332
146
  //
333
- // Memoised: rebuilt only when the plugin acquires (null → defined)
334
- // or when the FoV props change (cheap math but they're in the
335
- // closure so they must be in the deps). Shared values are NOT in
336
- // the deps Reanimated wires their .value reads through the
337
- // worklet's frozen runtime independently of React's render cycle.
147
+ // `stitcher.call` is itself a worklet (see `useStitcherWorklet`),
148
+ // so we just forward each frame to it. Memoised on
149
+ // [stitcher.call] so the host's `<Camera>` doesn't see frame-
150
+ // processor identity churn on every render only when the
151
+ // underlying plugin acquires (null non-null).
338
152
  const frameProcessor = useFrameProcessor((frame) => {
339
153
  'worklet';
340
- if (plugin == null) return;
341
-
342
- // Throttle: only every Nth frame. Counter increments first so
343
- // frame #0 is "due" (N>=1 always divides 0). Cheaper than
344
- // calling the plugin on rejected frames; saves the ~1 µs
345
- // marshalling cost per skip.
346
- sharedFrameCounter.value += 1;
347
- const N = sharedEvalEveryN.value;
348
- if (N > 1 && (sharedFrameCounter.value % N) !== 0) return;
154
+ stitcher.call(frame);
155
+ }, [stitcher.call]);
349
156
 
350
- // Synthesise quaternion from accumulated yaw + pitch + roll.
351
- // YPR Tait-Bryan order: q = q_yaw * q_pitch * q_roll. When
352
- // roll=0 this reduces to the legacy 2-axis form (cy*sp, sy*cp,
353
- // -sy*sp, cy*cp), so captures held level produce identical
354
- // poses to the pre-F8.3-followup-roll behaviour. See the
355
- // expanded math in the file header doc-comment.
356
- const halfYaw = sharedYaw.value / 2;
357
- const halfPitch = sharedPitch.value / 2;
358
- const halfRoll = sharedRoll.value / 2;
359
- const cy_ = Math.cos(halfYaw);
360
- const sy_ = Math.sin(halfYaw);
361
- const cp = Math.cos(halfPitch);
362
- const sp = Math.sin(halfPitch);
363
- const cr = Math.cos(halfRoll);
364
- const sr = Math.sin(halfRoll);
365
- const qx = cy_ * sp * cr + sy_ * cp * sr;
366
- const qy = sy_ * cp * cr - cy_ * sp * sr;
367
- const qz = cy_ * cp * sr - sy_ * sp * cr;
368
- const qw = cy_ * cp * cr + sy_ * sp * sr;
369
-
370
- // Intrinsics from FoV + actual frame dims.
371
- // fx = w * (1 / (2 * tan(fovH/2))) (the parenthesised half
372
- // is the precomputed `sharedFxNumerator` — see M1 fix).
373
- const w = frame.width;
374
- const h = frame.height;
375
- const fx = w * sharedFxNumerator.value;
376
- const fy = h * sharedFyNumerator.value;
377
-
378
- plugin.call(frame, {
379
- tx: 0, ty: 0, tz: 0,
380
- qx, qy, qz, qw,
381
- fx, fy,
382
- cx: w / 2, cy: h / 2,
383
- imageWidth: w, imageHeight: h,
384
- timestampMs: 0,
385
- // 2 == RNSARTrackingState.tracking — we always claim "good
386
- // tracking" because there's no ARKit signal to differentiate.
387
- // (Same contract as the pre-v0.6 useIncrementalJSDriver.)
388
- trackingStateRaw: 2,
389
- });
390
- // Deps array intentionally minimal: only `plugin` actually
391
- // requires worklet rebuild. All FoV / pose / counter / cadence
392
- // values flow through stable shared-value refs that Reanimated
393
- // wires through the producer-thread runtime independently of
394
- // React's render cycle. (Adversarial-review M1.)
395
- }, [plugin]);
396
-
397
- // ── Return handle ───────────────────────────────────────────────
398
- //
399
- // Returns a getter for `isRunning` so callers always see the live
400
- // state (the hook itself doesn't re-render on start/stop — that's
401
- // intentional, avoids stale-Camera-prop churn).
157
+ // Match pre-v0.11.0 contract: return `null` for `frameProcessor`
158
+ // until the underlying JSI plugin has resolved. `<Camera>` falls
159
+ // back to `undefined` in the null window so vision-camera doesn't
160
+ // try to bind an unready worklet.
402
161
  return useMemo<FrameProcessorDriverHandle>(() => ({
403
162
  start,
404
163
  stop,
405
- frameProcessor: plugin != null ? frameProcessor : null,
164
+ frameProcessor: stitcher.isReady ? frameProcessor : null,
406
165
  get isRunning() { return isRunningRef.current; },
407
- }), [start, stop, plugin, frameProcessor]);
166
+ }), [start, stop, frameProcessor, stitcher.isReady]);
408
167
  }
@@ -58,8 +58,7 @@
58
58
  // passed to `<Camera frameProcessor={...}>`. The hook returns
59
59
  // the processor object so hosts can wire it up either way.
60
60
 
61
- import { useCallback, useEffect, useMemo, useRef } from 'react';
62
- import { Platform } from 'react-native';
61
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
63
62
  import {
64
63
  VisionCameraProxy,
65
64
  type Frame,
@@ -73,6 +72,7 @@ import type {
73
72
  FrameStreamOptions,
74
73
  SampledFrame,
75
74
  } from '../types';
75
+ import { getDefaultCaptureDir } from '../utils/files';
76
76
 
77
77
  /**
78
78
  * `useFrameStream` — Layer 3. See module docstring for the full
@@ -111,37 +111,39 @@ export function useFrameStream(
111
111
  const sampleHz = Math.max(0.5, Math.min(10, options.sampleHz));
112
112
  const quality = options.quality ?? 75;
113
113
 
114
- // Default output dir: a per-app cache subdirectory. Hosts that
115
- // want a known path supply their own via `options.outputDir`.
116
- // `Platform.OS`-specific cache paths are read once at hook mount.
117
- const outputDir = useMemo(() => {
118
- if (options.outputDir != null) return options.outputDir;
119
- // Both platforms expose a cache directory at a predictable path
120
- // via React Native APIs; we use a small inline computation to
121
- // avoid pulling `react-native-fs` as a hard dep. The lib's
122
- // existing JPEG encode targets the app's data dir via similar
123
- // logic in `RNSARCameraView.kt` / `IncrementalStitcher.swift`.
124
- //
125
- // We just generate a relative-ish path under /tmp/ for cross-
126
- // platform simplicity; the native plugin writes wherever it's
127
- // told to (absolute path), so as long as the directory exists
128
- // the encode succeeds. Hosts that care about file lifecycle
129
- // should supply `outputDir` explicitly.
130
- return Platform.OS === 'ios'
131
- ? '/tmp/rnis-frame-stream'
132
- : '/data/local/tmp/rnis-frame-stream';
114
+ // Default output dir: the lib's canonical capture dir resolved
115
+ // via `FileBridge.defaultCaptureDir()`. Same dir the lib uses
116
+ // for panorama JPEGs / keyframe JPEGs guaranteed writable on
117
+ // both platforms (iOS NSCachesDirectory + Android Context.cacheDir),
118
+ // created if missing. Resolved async on first mount; until
119
+ // resolution completes the worklet's `outputDir` is empty and
120
+ // the plugin call no-ops silently (a few frames missed at most;
121
+ // typical resolution time is <50ms).
122
+ //
123
+ // Hosts that want a specific path supply `options.outputDir`
124
+ // and skip the resolution entirely.
125
+ const [resolvedDefaultDir, setResolvedDefaultDir] = useState<string>('');
126
+ useEffect(() => {
127
+ if (options.outputDir != null) return;
128
+ let cancelled = false;
129
+ getDefaultCaptureDir()
130
+ .then((dir) => {
131
+ if (!cancelled) setResolvedDefaultDir(dir);
132
+ })
133
+ .catch((err) => {
134
+ // eslint-disable-next-line no-console
135
+ console.warn(
136
+ '[useFrameStream] FileBridge.defaultCaptureDir() failed; ' +
137
+ 'samples will not fire until `options.outputDir` is supplied. ' +
138
+ String(err),
139
+ );
140
+ });
141
+ return () => {
142
+ cancelled = true;
143
+ };
133
144
  }, [options.outputDir]);
134
145
 
135
- // Ensure outputDir exists on the native side. We could use
136
- // react-native-fs but to keep the dep surface minimal, we just
137
- // attempt to create via a tiny native call — or, simpler, accept
138
- // that the plugin's write call will fail if the dir doesn't
139
- // exist + log a clear error. For v0.9.0 baseline we defer
140
- // mkdir to the host (document it in the option's JSDoc) OR fall
141
- // back to the platform's tmpdir which already exists.
142
- //
143
- // The tmpdir defaults above always exist on iOS + Android, so
144
- // the common case "host doesn't supply outputDir" Just Works.
146
+ const outputDir = options.outputDir ?? resolvedDefaultDir;
145
147
 
146
148
  // Stable JS-side handler reference for `runOnJS`. The hook re-
147
149
  // captures `handler` on every render but the ref keeps the
@@ -174,20 +176,37 @@ export function useFrameStream(
174
176
  // registry hasn't initialised yet (rare race on app start). We
175
177
  // retry every 16ms (one display frame) until success — matches
176
178
  // the pattern in `useFrameProcessorDriver`.
177
- const pluginRef = useRef<FrameProcessorPlugin | null>(null);
179
+ //
180
+ // Use `useState` (not `useRef`) so the eventual non-null value
181
+ // triggers a re-render — the worklet closure below captures
182
+ // `plugin` by value at render time, so without state we'd
183
+ // capture `null` forever.
184
+ const [plugin, setPlugin] = useState<FrameProcessorPlugin | null>(null);
178
185
  useEffect(() => {
179
186
  let cancelled = false;
180
187
  let timerId: ReturnType<typeof setTimeout> | null = null;
188
+ let attempts = 0;
181
189
  const tryAcquire = () => {
182
190
  if (cancelled) return;
191
+ attempts += 1;
183
192
  const p = VisionCameraProxy.initFrameProcessorPlugin(
184
193
  'save_frame_as_jpeg',
185
194
  {},
186
195
  );
187
196
  if (p != null) {
188
- pluginRef.current = p;
197
+ setPlugin(p);
189
198
  return;
190
199
  }
200
+ // After ~1s of failed retries, warn once — the plugin should
201
+ // be registered by then; persistent failure means the host's
202
+ // native bundle doesn't include `save_frame_as_jpeg`.
203
+ if (attempts === 60) {
204
+ // eslint-disable-next-line no-console
205
+ console.warn(
206
+ '[useFrameStream] save_frame_as_jpeg plugin not found after 1s of retries. ' +
207
+ 'Verify react-native-image-stitcher native module is installed in your host app.',
208
+ );
209
+ }
191
210
  timerId = setTimeout(tryAcquire, 16);
192
211
  };
193
212
  tryAcquire();
@@ -197,16 +216,13 @@ export function useFrameStream(
197
216
  };
198
217
  }, []);
199
218
 
200
- // The worklet body — fires at sampleHz, calls the JPEG plugin,
201
- // bridges the result to JS. Note we read `pluginRef.current`
202
- // inside the worklet via the captured `plugin` value below;
203
- // worklets-core handles the JS↔worklet reference.
204
- const plugin = pluginRef.current;
205
-
206
219
  return useThrottledFrameProcessor(
207
220
  (frame: StitcherFrame) => {
208
221
  'worklet';
209
222
  if (plugin == null) return;
223
+ // Async outputDir resolution may not have completed yet on
224
+ // the first few frames after mount — bail until it does.
225
+ if (outputDir === '') return;
210
226
 
211
227
  // Slot rotation: compute slot from frame timestamp. At
212
228
  // sampleHz=2 (500ms interval), the slot index changes every