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
@@ -249,6 +249,47 @@ export interface IncrementalState {
249
249
  * keyframes on disk.
250
250
  */
251
251
  refinedPanoramaPath?: string;
252
+ /**
253
+ * v0.10.0 (#15A) — current phase of an in-flight `refinePanorama`
254
+ * call. Fires from both the explicit `module.refinePanorama(...)`
255
+ * JS API path AND the hybrid-engine auto-refine path (which calls
256
+ * the same native refinePanorama internally).
257
+ *
258
+ * Lifecycle:
259
+ * - `"validating"` (fraction 0.05) — synchronous input checks
260
+ * - `"stitching"` (fraction 0.10) — OpenCV stitch in flight
261
+ * - `"writing"` (fraction 0.90) — stitch done, JPEG written
262
+ * - `"done"` (fraction 1.00) — success
263
+ * - `"error"` (fraction 1.00) — failure; `refineError` is set
264
+ *
265
+ * Coarse on purpose: OpenCV's Stitcher doesn't expose mid-pipeline
266
+ * progress, so the 0.10 → 0.90 jump is one opaque step. Use
267
+ * `refineStage` for a stage label; use `refineProgress` purely for
268
+ * spinner progress.
269
+ *
270
+ * Undefined when no refinement is in flight.
271
+ */
272
+ refineStage?: 'validating' | 'stitching' | 'writing' | 'done' | 'error';
273
+ /**
274
+ * v0.10.0 (#15A) — coarse progress fraction in `[0, 1]` aligned
275
+ * with `refineStage`. See `refineStage` for the per-stage value
276
+ * mapping. Undefined when no refinement is in flight.
277
+ */
278
+ refineProgress?: number;
279
+ /**
280
+ * v0.10.0 (#15A) — number of input frames the in-flight refine is
281
+ * processing. Useful for the UI label
282
+ * (`Stitching 6 frames…`). Mirrors the `framesRequested` field
283
+ * returned in the explicit refinePanorama resolution. Undefined
284
+ * when no refinement is in flight.
285
+ */
286
+ refineFrames?: number;
287
+ /**
288
+ * v0.10.0 (#15A) — present only when `refineStage === 'error'`.
289
+ * Human-readable error message; the same text the rejected promise
290
+ * carries. Use to render a one-line failure pill.
291
+ */
292
+ refineError?: string;
252
293
  }
253
294
  export interface IncrementalStartOptions {
254
295
  /**
@@ -4,99 +4,54 @@
4
4
  * from v0.6 onward (replaced the deprecated `useIncrementalJSDriver`
5
5
  * hook, which was removed in v0.6).
6
6
  *
7
- * Why this exists (vs the pre-v0.6 JS-driver predecessor)
8
- *
9
- * The old JS driver took a JPEG snapshot every ~250 ms and fed the
10
- * path to `IncrementalStitcher.processFrameAtPath` (both removed in
11
- * v0.6). That path had 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.
7
+ * v0.11.0 refactored to a thin wrapper around `useStitcherWorklet`.
8
+ * The plugin acquisition + shared-value declarations + gyro
9
+ * subscription + worklet body all live in `useStitcherWorklet` now;
10
+ * this hook just binds the returned worklet via vision-camera's
11
+ * `useFrameProcessor` and exposes the legacy `start` / `stop` /
12
+ * `isRunning` API for backwards compatibility with v0.10.x.
13
+ *
14
+ * ## Why the v0.11.0 split
15
+ *
16
+ * vision-camera v4 allows ONE frame processor per `<Camera>` mount.
17
+ * Pre-v0.11.0, hosts that wanted to compose their own worklet with
18
+ * the lib's first-party stitching couldn't passing a host
19
+ * `frameProcessor` REPLACED the lib's processor. v0.11.0 closes
20
+ * this gap by exposing the worklet body via `useStitcherWorklet`
21
+ * so hosts can write:
22
+ *
23
+ * const stitcher = useStitcherWorklet();
24
+ * const fp = useFrameProcessor((frame) => {
25
+ * 'worklet';
26
+ * hostPreLogic(frame);
27
+ * stitcher.call(frame); // first-party stitching
28
+ * hostPostLogic(frame);
29
+ * }, [stitcher.call]);
30
+ *
31
+ * `useFrameProcessorDriver` keeps the legacy default-integration
32
+ * shape (start / stop / isRunning) for the `<Camera>` component's
33
+ * built-in non-AR path and for any host still using the v0.10.x API
34
+ * directly. No behavioural change for those callers.
35
+ *
36
+ * ## start / stop behaviour
37
+ *
38
+ * - `start()` calls `stitcher.reset()` to zero the accumulated
39
+ * pose (preserves the pre-v0.11.0 "each capture starts with
40
+ * pose = (0, 0, 0)" contract).
41
+ * - `stop()` also resets the pose (idempotent; matches the
42
+ * pre-v0.11.0 stop() side effect of zeroing yaw / pitch / roll).
43
+ * - The gyro subscription itself is owned by `useStitcherWorklet`
44
+ * and runs for the lifetime of the hook. In the default
45
+ * `<Camera>` integration this means gyro is on while the camera
46
+ * screen is mounted same practical scope as pre-v0.11.0 in
47
+ * all observed host integrations (capture screens mount
48
+ * `<Camera>` for the duration of capture; idle screens don't).
49
+ *
50
+ * ## Pose synthesis / intrinsics / throttling
51
+ *
52
+ * Owned by `useStitcherWorklet`. See that file's header for the
53
+ * quaternion math, FoV-to-intrinsics derivation, throttle gate, and
54
+ * pairing-with-IncrementalStitcher.start docs.
100
55
  */
101
56
  import type { ReadonlyFrameProcessor } from 'react-native-vision-camera';
102
57
  export interface UseFrameProcessorDriverOptions {
@@ -131,9 +86,9 @@ export interface UseFrameProcessorDriverOptions {
131
86
  evalEveryNFrames?: number;
132
87
  }
133
88
  export interface FrameProcessorDriverHandle {
134
- /** Subscribe to the gyro + reset pose accumulators. Idempotent. */
89
+ /** Reset pose accumulators + mark the driver as running. Idempotent. */
135
90
  start: () => void;
136
- /** Unsubscribe + reset pose. */
91
+ /** Reset pose + mark the driver as stopped. Idempotent. */
137
92
  stop: () => void;
138
93
  /**
139
94
  * Pass this to `<Camera frameProcessor={...} />`. `null` until
@@ -6,317 +6,99 @@
6
6
  * from v0.6 onward (replaced the deprecated `useIncrementalJSDriver`
7
7
  * hook, which was removed in v0.6).
8
8
  *
9
- * Why this exists (vs the pre-v0.6 JS-driver predecessor)
10
- *
11
- * The old JS driver took a JPEG snapshot every ~250 ms and fed the
12
- * path to `IncrementalStitcher.processFrameAtPath` (both removed in
13
- * v0.6). That path had three costs:
14
- *
15
- * 1. JPEG encode (`takeSnapshot` ≈ 30–80 ms on iPhone 16 Pro)
16
- * 2. Disk write of the JPEG
17
- * 3. JPEG decode + cv::Mat alloc inside the engine
18
- *
19
- * Per-frame round-trip ~80 ms means ~4 Hz max throughput, and
20
- * ~80 ms latency between "this is the moment to accept" and "this
21
- * frame is in the engine". Both numbers caused operator-felt lag
22
- * on long shelf pans.
23
- *
24
- * This hook uses vision-camera's Frame Processor instead. The
25
- * worklet runs on the camera producer thread at the native frame
26
- * rate (30 fps on iOS). Each frame goes through a JSI plugin
27
- * (`cv_flow_gate_process_frame`) directly into
28
- * `IncrementalStitcher.consumeFrame` — the SAME entry point AR
29
- * mode uses, with the engine's existing KeyframeGate making the
30
- * accept/reject decision. Rejected frames cost ~3–8 ms; accepted
31
- * frames take the same deep-copy + workQueue path AR mode takes.
32
- *
33
- * Net wins: no JPEG round-trip on rejected frames, no disk thrash
34
- * during recording, lower latency to accept, full 30 fps gate
35
- * evaluation budget.
36
- *
37
- * Pose synthesis
38
- *
39
- * Non-AR mode has no ARKit pose. We integrate the gyroscope on
40
- * the JS thread (`react-native-sensors`), accumulate yaw + pitch,
41
- * and publish them via Reanimated `useSharedValue` so the worklet
42
- * can read them WITHOUT a thread hop. Translation is reported as
43
- * zero (no IMU translation; this is a known limitation we share
44
- * with the legacy driver drift ~1–2°/min over a 30 s capture is
45
- * below the gate's overlap threshold and rarely matters).
46
- *
47
- * Quaternion synthesis (q = q_yaw * q_pitch * q_roll, Tait-Bryan
48
- * YPR order to match the legacy driver's body-frame intent):
49
- * q_yaw = (0, sin(yaw/2), 0, cos(yaw/2))
50
- * q_pitch = (sin(pitch/2), 0, 0, cos(pitch/2))
51
- * q_roll = (0, 0, sin(roll/2), cos(roll/2))
52
- *
53
- * Expanded (cy, sy = cos/sin(yaw/2); analogous for cp/sp, cr/sr):
54
- * qx = cy*sp*cr + sy*cp*sr
55
- * qy = sy*cp*cr - cy*sp*sr
56
- * qz = cy*cp*sr - sy*sp*cr
57
- * qw = cy*cp*cr + sy*sp*sr
58
- *
59
- * When roll=0 this collapses to the 2-axis form
60
- * `(cy*sp, sy*cp, -sy*sp, cy*cp)` the legacy driver used, so
61
- * captures held perfectly level produce identical poses to the
62
- * pre-roll behaviour.
63
- *
64
- * Intrinsics are synthesised from the actual frame dimensions
65
- * (`frame.width`, `frame.height`) plus the host-provided
66
- * horizontal/vertical FoV defaults. The stitcher derives its FoV-
67
- * overlap window from these, so the assumed FoV matters for the
68
- * gate's overlap math but not for the panorama itself (the
69
- * stitcher feature-matches + RANSACs the final alignment).
70
- *
71
- * Throttling
72
- *
73
- * `evalEveryNFrames` controls how often the worklet calls the
74
- * plugin. Default 1 (every frame). Set higher to amortise the
75
- * plugin call + consumeFrame's gate evaluation across multiple
76
- * producer-thread frames on lower-end devices. Independent of —
77
- * and stacks on top of — the stitcher's own internal
78
- * `flowEvalEveryNFrames` (see `KeyframeGate.swift`); both
79
- * throttles can be active simultaneously and the effective cadence
80
- * is `evalEveryNFrames * flowEvalEveryNFrames`.
81
- *
82
- * Lifecycle
83
- *
84
- * `start()` subscribes to the gyro and resets pose accumulators.
85
- * `stop()` unsubscribes and resets. The returned `frameProcessor`
86
- * is meant to be passed to `<Camera frameProcessor={...} />` —
87
- * it's stable as long as the plugin reference and the FoV props
88
- * haven't changed. Returns `null` when the plugin isn't loaded
89
- * yet; pass `null`-or-fallback to the Camera in that case.
90
- *
91
- * Pairing with `IncrementalStitcher.start({frameSourceMode})`
92
- *
93
- * The plugin's per-frame call into `consumeFrameFromPlugin` is
94
- * gated by `IncrementalStitcher.frameProcessorIngestEnabled`,
95
- * which is TRUE only when the stitcher was started with
96
- * `frameSourceMode === 'frameProcessor'`. Hosts MUST call
97
- * `incrementalStitcher.start({ frameSourceMode: 'frameProcessor',
98
- * ... })` to actually get frames into the engine — otherwise the
99
- * worklet runs to completion but the wrapper drops the call.
100
- * `Camera.tsx` does this wiring automatically when the host opts
101
- * into this driver.
9
+ * v0.11.0 refactored to a thin wrapper around `useStitcherWorklet`.
10
+ * The plugin acquisition + shared-value declarations + gyro
11
+ * subscription + worklet body all live in `useStitcherWorklet` now;
12
+ * this hook just binds the returned worklet via vision-camera's
13
+ * `useFrameProcessor` and exposes the legacy `start` / `stop` /
14
+ * `isRunning` API for backwards compatibility with v0.10.x.
15
+ *
16
+ * ## Why the v0.11.0 split
17
+ *
18
+ * vision-camera v4 allows ONE frame processor per `<Camera>` mount.
19
+ * Pre-v0.11.0, hosts that wanted to compose their own worklet with
20
+ * the lib's first-party stitching couldn't passing a host
21
+ * `frameProcessor` REPLACED the lib's processor. v0.11.0 closes
22
+ * this gap by exposing the worklet body via `useStitcherWorklet`
23
+ * so hosts can write:
24
+ *
25
+ * const stitcher = useStitcherWorklet();
26
+ * const fp = useFrameProcessor((frame) => {
27
+ * 'worklet';
28
+ * hostPreLogic(frame);
29
+ * stitcher.call(frame); // first-party stitching
30
+ * hostPostLogic(frame);
31
+ * }, [stitcher.call]);
32
+ *
33
+ * `useFrameProcessorDriver` keeps the legacy default-integration
34
+ * shape (start / stop / isRunning) for the `<Camera>` component's
35
+ * built-in non-AR path and for any host still using the v0.10.x API
36
+ * directly. No behavioural change for those callers.
37
+ *
38
+ * ## start / stop behaviour
39
+ *
40
+ * - `start()` calls `stitcher.reset()` to zero the accumulated
41
+ * pose (preserves the pre-v0.11.0 "each capture starts with
42
+ * pose = (0, 0, 0)" contract).
43
+ * - `stop()` also resets the pose (idempotent; matches the
44
+ * pre-v0.11.0 stop() side effect of zeroing yaw / pitch / roll).
45
+ * - The gyro subscription itself is owned by `useStitcherWorklet`
46
+ * and runs for the lifetime of the hook. In the default
47
+ * `<Camera>` integration this means gyro is on while the camera
48
+ * screen is mounted same practical scope as pre-v0.11.0 in
49
+ * all observed host integrations (capture screens mount
50
+ * `<Camera>` for the duration of capture; idle screens don't).
51
+ *
52
+ * ## Pose synthesis / intrinsics / throttling
53
+ *
54
+ * Owned by `useStitcherWorklet`. See that file's header for the
55
+ * quaternion math, FoV-to-intrinsics derivation, throttle gate, and
56
+ * pairing-with-IncrementalStitcher.start docs.
102
57
  */
103
58
  Object.defineProperty(exports, "__esModule", { value: true });
104
59
  exports.useFrameProcessorDriver = useFrameProcessorDriver;
105
60
  const react_1 = require("react");
106
- const react_native_sensors_1 = require("react-native-sensors");
107
- // Reanimated's `useSharedValue` is the documented vision-camera
108
- // idiom, but it's a heavy peer dep. `react-native-worklets-core`
109
- // (already a transitive dep via vision-camera v4 on RN 0.84) exposes
110
- // the same API surface (a `value` getter/setter readable from
111
- // worklets and the JS thread) and is sufficient for our use.
112
- const react_native_worklets_core_1 = require("react-native-worklets-core");
113
61
  const react_native_vision_camera_1 = require("react-native-vision-camera");
62
+ const useStitcherWorklet_1 = require("./useStitcherWorklet");
114
63
  function useFrameProcessorDriver(options = {}) {
115
- const { gyroIntervalMs = 33, fovHorizDegrees = 65, fovVertDegrees = 50, evalEveryNFrames = 1, } = options;
116
- // ── Plugin acquisition ──────────────────────────────────────────
117
- //
118
- // `initFrameProcessorPlugin` can return `undefined` if called
119
- // before vision-camera's plugin registry has finished initialising
120
- // (race observed in F8.1.a). We retry on a fixed timer instead of
121
- // firing on every render — the earlier render-driven pattern
122
- // (adversarial-review H3) re-invoked `initFrameProcessorPlugin`
123
- // 60+ times per second during recording, and the vision-camera
124
- // contract for repeated lookups is undocumented.
125
- //
126
- // Pattern: mount-once useEffect, try synchronously, fall back to a
127
- // 16-ms retry timer until success or unmount.
128
- const [plugin, setPlugin] = (0, react_1.useState)(null);
129
- (0, react_1.useEffect)(() => {
130
- let cancelled = false;
131
- let timerId = null;
132
- const tryAcquire = () => {
133
- if (cancelled)
134
- return;
135
- const p = react_native_vision_camera_1.VisionCameraProxy.initFrameProcessorPlugin('cv_flow_gate_process_frame', {});
136
- if (p != null) {
137
- setPlugin(p);
138
- return;
139
- }
140
- // ~one display-frame retry — matches the F8.1.a observation
141
- // that the registry becomes ready by the next render tick.
142
- timerId = setTimeout(tryAcquire, 16);
143
- };
144
- tryAcquire();
145
- return () => {
146
- cancelled = true;
147
- if (timerId != null)
148
- clearTimeout(timerId);
149
- };
150
- // Empty deps on purpose — runs ONCE on mount. Re-acquiring on
151
- // re-render would race with worklet binding.
152
- // eslint-disable-next-line react-hooks/exhaustive-deps
153
- }, []);
154
- // ── Shared values (worklet ↔ JS thread) ─────────────────────────
155
- //
156
- // Reanimated guarantees coherent reads from the producer thread.
157
- // We write yaw/pitch on the JS thread (gyro callbacks); the worklet
158
- // reads them every frame. No round-trip cost — these are mapped
159
- // into the worklet's runtime by the Reanimated bridge.
160
- //
161
- // FoV-derived values (the "half-angle tangent reciprocal"
162
- // f-numerators) are pre-computed on the JS thread + published via
163
- // shared values so the worklet's dependency array shrinks to just
164
- // `[plugin]`. Earlier draft baked `fovHorizDegrees` /
165
- // `fovVertDegrees` into the closure → worklet re-serialised on
166
- // every host re-render that changed the prop refs (adversarial-
167
- // review M1).
168
- const sharedYaw = (0, react_native_worklets_core_1.useSharedValue)(0);
169
- const sharedPitch = (0, react_native_worklets_core_1.useSharedValue)(0);
170
- // F8.3-followup-roll — integrate gyroscope Z (out-of-screen for a
171
- // portrait device) to track wrist-twist roll. Field captures with
172
- // casual hand-hold rarely stay perfectly level; without this the
173
- // pose stream lies and the cv::Stitcher's intrinsic estimator may
174
- // pick a worse projection mode.
175
- const sharedRoll = (0, react_native_worklets_core_1.useSharedValue)(0);
176
- const sharedFrameCounter = (0, react_native_worklets_core_1.useSharedValue)(0);
177
- const sharedEvalEveryN = (0, react_native_worklets_core_1.useSharedValue)(Math.max(1, evalEveryNFrames));
178
- const sharedFxNumerator = (0, react_native_worklets_core_1.useSharedValue)(1.0 / (2.0 * Math.tan((fovHorizDegrees * Math.PI / 180) / 2)));
179
- const sharedFyNumerator = (0, react_native_worklets_core_1.useSharedValue)(1.0 / (2.0 * Math.tan((fovVertDegrees * Math.PI / 180) / 2)));
180
- // Keep prop-derived shared values in sync. Cheap re-renders;
181
- // these don't trigger worklet rebuild.
182
- (0, react_1.useEffect)(() => {
183
- sharedEvalEveryN.value = Math.max(1, evalEveryNFrames);
184
- }, [evalEveryNFrames, sharedEvalEveryN]);
185
- (0, react_1.useEffect)(() => {
186
- sharedFxNumerator.value =
187
- 1.0 / (2.0 * Math.tan((fovHorizDegrees * Math.PI / 180) / 2));
188
- }, [fovHorizDegrees, sharedFxNumerator]);
189
- (0, react_1.useEffect)(() => {
190
- sharedFyNumerator.value =
191
- 1.0 / (2.0 * Math.tan((fovVertDegrees * Math.PI / 180) / 2));
192
- }, [fovVertDegrees, sharedFyNumerator]);
193
- // ── Lifecycle state (JS thread only) ────────────────────────────
194
- const gyroSubRef = (0, react_1.useRef)(null);
195
- const lastGyroAtRef = (0, react_1.useRef)(null);
64
+ // v0.11.0 delegate plugin / shared values / gyro / worklet body
65
+ // to `useStitcherWorklet`. This hook is now a thin wrapper that
66
+ // binds the returned worklet via `useFrameProcessor` and exposes
67
+ // the legacy lifecycle API.
68
+ const stitcher = (0, useStitcherWorklet_1.useStitcherWorklet)(options);
196
69
  const isRunningRef = (0, react_1.useRef)(false);
197
- const stop = (0, react_1.useCallback)(() => {
198
- if (gyroSubRef.current) {
199
- gyroSubRef.current.unsubscribe();
200
- gyroSubRef.current = null;
201
- }
202
- isRunningRef.current = false;
203
- sharedYaw.value = 0;
204
- sharedPitch.value = 0;
205
- sharedRoll.value = 0;
206
- sharedFrameCounter.value = 0;
207
- lastGyroAtRef.current = null;
208
- }, [sharedYaw, sharedPitch, sharedRoll, sharedFrameCounter]);
209
70
  const start = (0, react_1.useCallback)(() => {
210
71
  if (isRunningRef.current)
211
72
  return;
212
- sharedYaw.value = 0;
213
- sharedPitch.value = 0;
214
- sharedRoll.value = 0;
215
- sharedFrameCounter.value = 0;
216
- lastGyroAtRef.current = null;
73
+ stitcher.reset();
217
74
  isRunningRef.current = true;
218
- // Gyro integration. Each sample carries angular velocity in
219
- // rad/s; multiply by dt to accumulate displacement. Axes for a
220
- // device held portrait:
221
- // y = horizontal pan (yaw, about world-Y)
222
- // x = vertical tilt (pitch, about world-X)
223
- // z = wrist-twist roll (about world-Z, normal to the screen)
224
- // Right-hand-rule convention throughout — same signs the pre-v0.6
225
- // `useIncrementalJSDriver` produced. If field captures show
226
- // inverted roll, flip the sign on `z * dt` below.
227
- (0, react_native_sensors_1.setUpdateIntervalForType)(react_native_sensors_1.SensorTypes.gyroscope, gyroIntervalMs);
228
- gyroSubRef.current = react_native_sensors_1.gyroscope.subscribe({
229
- next: ({ x, y, z }) => {
230
- const now = Date.now();
231
- if (lastGyroAtRef.current === null) {
232
- lastGyroAtRef.current = now;
233
- return;
234
- }
235
- const dt = (now - lastGyroAtRef.current) / 1000.0;
236
- lastGyroAtRef.current = now;
237
- sharedYaw.value += y * dt;
238
- sharedPitch.value += x * dt;
239
- sharedRoll.value += z * dt;
240
- },
241
- error: (err) => {
242
- // eslint-disable-next-line no-console
243
- console.warn('[useFrameProcessorDriver] gyro error', err);
244
- },
245
- });
246
- }, [gyroIntervalMs, sharedYaw, sharedPitch, sharedRoll, sharedFrameCounter]);
247
- // ── Worklet ─────────────────────────────────────────────────────
75
+ }, [stitcher]);
76
+ const stop = (0, react_1.useCallback)(() => {
77
+ if (!isRunningRef.current)
78
+ return;
79
+ stitcher.reset();
80
+ isRunningRef.current = false;
81
+ }, [stitcher]);
82
+ // ── Worklet binding ─────────────────────────────────────────────
248
83
  //
249
- // Memoised: rebuilt only when the plugin acquires (null → defined)
250
- // or when the FoV props change (cheap math but they're in the
251
- // closure so they must be in the deps). Shared values are NOT in
252
- // the deps Reanimated wires their .value reads through the
253
- // worklet's frozen runtime independently of React's render cycle.
84
+ // `stitcher.call` is itself a worklet (see `useStitcherWorklet`),
85
+ // so we just forward each frame to it. Memoised on
86
+ // [stitcher.call] so the host's `<Camera>` doesn't see frame-
87
+ // processor identity churn on every render only when the
88
+ // underlying plugin acquires (null non-null).
254
89
  const frameProcessor = (0, react_native_vision_camera_1.useFrameProcessor)((frame) => {
255
90
  'worklet';
256
- if (plugin == null)
257
- return;
258
- // Throttle: only every Nth frame. Counter increments first so
259
- // frame #0 is "due" (N>=1 always divides 0). Cheaper than
260
- // calling the plugin on rejected frames; saves the ~1 µs
261
- // marshalling cost per skip.
262
- sharedFrameCounter.value += 1;
263
- const N = sharedEvalEveryN.value;
264
- if (N > 1 && (sharedFrameCounter.value % N) !== 0)
265
- return;
266
- // Synthesise quaternion from accumulated yaw + pitch + roll.
267
- // YPR Tait-Bryan order: q = q_yaw * q_pitch * q_roll. When
268
- // roll=0 this reduces to the legacy 2-axis form (cy*sp, sy*cp,
269
- // -sy*sp, cy*cp), so captures held level produce identical
270
- // poses to the pre-F8.3-followup-roll behaviour. See the
271
- // expanded math in the file header doc-comment.
272
- const halfYaw = sharedYaw.value / 2;
273
- const halfPitch = sharedPitch.value / 2;
274
- const halfRoll = sharedRoll.value / 2;
275
- const cy_ = Math.cos(halfYaw);
276
- const sy_ = Math.sin(halfYaw);
277
- const cp = Math.cos(halfPitch);
278
- const sp = Math.sin(halfPitch);
279
- const cr = Math.cos(halfRoll);
280
- const sr = Math.sin(halfRoll);
281
- const qx = cy_ * sp * cr + sy_ * cp * sr;
282
- const qy = sy_ * cp * cr - cy_ * sp * sr;
283
- const qz = cy_ * cp * sr - sy_ * sp * cr;
284
- const qw = cy_ * cp * cr + sy_ * sp * sr;
285
- // Intrinsics from FoV + actual frame dims.
286
- // fx = w * (1 / (2 * tan(fovH/2))) (the parenthesised half
287
- // is the precomputed `sharedFxNumerator` — see M1 fix).
288
- const w = frame.width;
289
- const h = frame.height;
290
- const fx = w * sharedFxNumerator.value;
291
- const fy = h * sharedFyNumerator.value;
292
- plugin.call(frame, {
293
- tx: 0, ty: 0, tz: 0,
294
- qx, qy, qz, qw,
295
- fx, fy,
296
- cx: w / 2, cy: h / 2,
297
- imageWidth: w, imageHeight: h,
298
- timestampMs: 0,
299
- // 2 == RNSARTrackingState.tracking — we always claim "good
300
- // tracking" because there's no ARKit signal to differentiate.
301
- // (Same contract as the pre-v0.6 useIncrementalJSDriver.)
302
- trackingStateRaw: 2,
303
- });
304
- // Deps array intentionally minimal: only `plugin` actually
305
- // requires worklet rebuild. All FoV / pose / counter / cadence
306
- // values flow through stable shared-value refs that Reanimated
307
- // wires through the producer-thread runtime independently of
308
- // React's render cycle. (Adversarial-review M1.)
309
- }, [plugin]);
310
- // ── Return handle ───────────────────────────────────────────────
311
- //
312
- // Returns a getter for `isRunning` so callers always see the live
313
- // state (the hook itself doesn't re-render on start/stop — that's
314
- // intentional, avoids stale-Camera-prop churn).
91
+ stitcher.call(frame);
92
+ }, [stitcher.call]);
93
+ // Match pre-v0.11.0 contract: return `null` for `frameProcessor`
94
+ // until the underlying JSI plugin has resolved. `<Camera>` falls
95
+ // back to `undefined` in the null window so vision-camera doesn't
96
+ // try to bind an unready worklet.
315
97
  return (0, react_1.useMemo)(() => ({
316
98
  start,
317
99
  stop,
318
- frameProcessor: plugin != null ? frameProcessor : null,
100
+ frameProcessor: stitcher.isReady ? frameProcessor : null,
319
101
  get isRunning() { return isRunningRef.current; },
320
- }), [start, stop, plugin, frameProcessor]);
102
+ }), [start, stop, frameProcessor, stitcher.isReady]);
321
103
  }
322
104
  //# sourceMappingURL=useFrameProcessorDriver.js.map