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,321 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * useFrameProcessorDriver — vision-camera Frame Processor + gyro
5
+ * driver for the incremental panorama engine. Replaces
6
+ * `useIncrementalJSDriver` in non-AR captures.
7
+ *
8
+ * Why this exists (vs the JS-driver predecessor)
9
+ *
10
+ * The JS driver takes a JPEG snapshot every ~250 ms and feeds the
11
+ * path to `IncrementalStitcher.processFrameAtPath`. That path
12
+ * has 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.
101
+ */
102
+ Object.defineProperty(exports, "__esModule", { value: true });
103
+ exports.useFrameProcessorDriver = useFrameProcessorDriver;
104
+ const react_1 = require("react");
105
+ const react_native_sensors_1 = require("react-native-sensors");
106
+ // Reanimated's `useSharedValue` is the documented vision-camera
107
+ // idiom, but it's a heavy peer dep. `react-native-worklets-core`
108
+ // (already a transitive dep via vision-camera v4 on RN 0.84) exposes
109
+ // the same API surface (a `value` getter/setter readable from
110
+ // worklets and the JS thread) and is sufficient for our use.
111
+ const react_native_worklets_core_1 = require("react-native-worklets-core");
112
+ const react_native_vision_camera_1 = require("react-native-vision-camera");
113
+ function useFrameProcessorDriver(options = {}) {
114
+ const { gyroIntervalMs = 33, fovHorizDegrees = 65, fovVertDegrees = 50, evalEveryNFrames = 1, } = options;
115
+ // ── Plugin acquisition ──────────────────────────────────────────
116
+ //
117
+ // `initFrameProcessorPlugin` can return `undefined` if called
118
+ // before vision-camera's plugin registry has finished initialising
119
+ // (race observed in F8.1.a). We retry on a fixed timer instead of
120
+ // firing on every render — the earlier render-driven pattern
121
+ // (adversarial-review H3) re-invoked `initFrameProcessorPlugin`
122
+ // 60+ times per second during recording, and the vision-camera
123
+ // contract for repeated lookups is undocumented.
124
+ //
125
+ // Pattern: mount-once useEffect, try synchronously, fall back to a
126
+ // 16-ms retry timer until success or unmount.
127
+ const [plugin, setPlugin] = (0, react_1.useState)(null);
128
+ (0, react_1.useEffect)(() => {
129
+ let cancelled = false;
130
+ let timerId = null;
131
+ const tryAcquire = () => {
132
+ if (cancelled)
133
+ return;
134
+ const p = react_native_vision_camera_1.VisionCameraProxy.initFrameProcessorPlugin('cv_flow_gate_process_frame', {});
135
+ if (p != null) {
136
+ setPlugin(p);
137
+ return;
138
+ }
139
+ // ~one display-frame retry — matches the F8.1.a observation
140
+ // that the registry becomes ready by the next render tick.
141
+ timerId = setTimeout(tryAcquire, 16);
142
+ };
143
+ tryAcquire();
144
+ return () => {
145
+ cancelled = true;
146
+ if (timerId != null)
147
+ clearTimeout(timerId);
148
+ };
149
+ // Empty deps on purpose — runs ONCE on mount. Re-acquiring on
150
+ // re-render would race with worklet binding.
151
+ // eslint-disable-next-line react-hooks/exhaustive-deps
152
+ }, []);
153
+ // ── Shared values (worklet ↔ JS thread) ─────────────────────────
154
+ //
155
+ // Reanimated guarantees coherent reads from the producer thread.
156
+ // We write yaw/pitch on the JS thread (gyro callbacks); the worklet
157
+ // reads them every frame. No round-trip cost — these are mapped
158
+ // into the worklet's runtime by the Reanimated bridge.
159
+ //
160
+ // FoV-derived values (the "half-angle tangent reciprocal"
161
+ // f-numerators) are pre-computed on the JS thread + published via
162
+ // shared values so the worklet's dependency array shrinks to just
163
+ // `[plugin]`. Earlier draft baked `fovHorizDegrees` /
164
+ // `fovVertDegrees` into the closure → worklet re-serialised on
165
+ // every host re-render that changed the prop refs (adversarial-
166
+ // review M1).
167
+ const sharedYaw = (0, react_native_worklets_core_1.useSharedValue)(0);
168
+ const sharedPitch = (0, react_native_worklets_core_1.useSharedValue)(0);
169
+ // F8.3-followup-roll — integrate gyroscope Z (out-of-screen for a
170
+ // portrait device) to track wrist-twist roll. Field captures with
171
+ // casual hand-hold rarely stay perfectly level; without this the
172
+ // pose stream lies and the cv::Stitcher's intrinsic estimator may
173
+ // pick a worse projection mode.
174
+ const sharedRoll = (0, react_native_worklets_core_1.useSharedValue)(0);
175
+ const sharedFrameCounter = (0, react_native_worklets_core_1.useSharedValue)(0);
176
+ const sharedEvalEveryN = (0, react_native_worklets_core_1.useSharedValue)(Math.max(1, evalEveryNFrames));
177
+ const sharedFxNumerator = (0, react_native_worklets_core_1.useSharedValue)(1.0 / (2.0 * Math.tan((fovHorizDegrees * Math.PI / 180) / 2)));
178
+ const sharedFyNumerator = (0, react_native_worklets_core_1.useSharedValue)(1.0 / (2.0 * Math.tan((fovVertDegrees * Math.PI / 180) / 2)));
179
+ // Keep prop-derived shared values in sync. Cheap re-renders;
180
+ // these don't trigger worklet rebuild.
181
+ (0, react_1.useEffect)(() => {
182
+ sharedEvalEveryN.value = Math.max(1, evalEveryNFrames);
183
+ }, [evalEveryNFrames, sharedEvalEveryN]);
184
+ (0, react_1.useEffect)(() => {
185
+ sharedFxNumerator.value =
186
+ 1.0 / (2.0 * Math.tan((fovHorizDegrees * Math.PI / 180) / 2));
187
+ }, [fovHorizDegrees, sharedFxNumerator]);
188
+ (0, react_1.useEffect)(() => {
189
+ sharedFyNumerator.value =
190
+ 1.0 / (2.0 * Math.tan((fovVertDegrees * Math.PI / 180) / 2));
191
+ }, [fovVertDegrees, sharedFyNumerator]);
192
+ // ── Lifecycle state (JS thread only) ────────────────────────────
193
+ const gyroSubRef = (0, react_1.useRef)(null);
194
+ const lastGyroAtRef = (0, react_1.useRef)(null);
195
+ const isRunningRef = (0, react_1.useRef)(false);
196
+ const stop = (0, react_1.useCallback)(() => {
197
+ if (gyroSubRef.current) {
198
+ gyroSubRef.current.unsubscribe();
199
+ gyroSubRef.current = null;
200
+ }
201
+ isRunningRef.current = false;
202
+ sharedYaw.value = 0;
203
+ sharedPitch.value = 0;
204
+ sharedRoll.value = 0;
205
+ sharedFrameCounter.value = 0;
206
+ lastGyroAtRef.current = null;
207
+ }, [sharedYaw, sharedPitch, sharedRoll, sharedFrameCounter]);
208
+ const start = (0, react_1.useCallback)(() => {
209
+ if (isRunningRef.current)
210
+ return;
211
+ sharedYaw.value = 0;
212
+ sharedPitch.value = 0;
213
+ sharedRoll.value = 0;
214
+ sharedFrameCounter.value = 0;
215
+ lastGyroAtRef.current = null;
216
+ isRunningRef.current = true;
217
+ // Gyro integration. Each sample carries angular velocity in
218
+ // rad/s; multiply by dt to accumulate displacement. Axes for a
219
+ // device held portrait:
220
+ // y = horizontal pan (yaw, about world-Y)
221
+ // x = vertical tilt (pitch, about world-X)
222
+ // z = wrist-twist roll (about world-Z, normal to the screen)
223
+ // Signs match the legacy `useIncrementalJSDriver` for x/y; z
224
+ // follows the same right-hand-rule convention. If field
225
+ // captures show inverted roll, flip the sign on `z * dt` below.
226
+ (0, react_native_sensors_1.setUpdateIntervalForType)(react_native_sensors_1.SensorTypes.gyroscope, gyroIntervalMs);
227
+ gyroSubRef.current = react_native_sensors_1.gyroscope.subscribe({
228
+ next: ({ x, y, z }) => {
229
+ const now = Date.now();
230
+ if (lastGyroAtRef.current === null) {
231
+ lastGyroAtRef.current = now;
232
+ return;
233
+ }
234
+ const dt = (now - lastGyroAtRef.current) / 1000.0;
235
+ lastGyroAtRef.current = now;
236
+ sharedYaw.value += y * dt;
237
+ sharedPitch.value += x * dt;
238
+ sharedRoll.value += z * dt;
239
+ },
240
+ error: (err) => {
241
+ // eslint-disable-next-line no-console
242
+ console.warn('[useFrameProcessorDriver] gyro error', err);
243
+ },
244
+ });
245
+ }, [gyroIntervalMs, sharedYaw, sharedPitch, sharedRoll, sharedFrameCounter]);
246
+ // ── Worklet ─────────────────────────────────────────────────────
247
+ //
248
+ // Memoised: rebuilt only when the plugin acquires (null → defined)
249
+ // or when the FoV props change (cheap math but they're in the
250
+ // closure so they must be in the deps). Shared values are NOT in
251
+ // the deps — Reanimated wires their .value reads through the
252
+ // worklet's frozen runtime independently of React's render cycle.
253
+ const frameProcessor = (0, react_native_vision_camera_1.useFrameProcessor)((frame) => {
254
+ 'worklet';
255
+ if (plugin == null)
256
+ return;
257
+ // Throttle: only every Nth frame. Counter increments first so
258
+ // frame #0 is "due" (N>=1 always divides 0). Cheaper than
259
+ // calling the plugin on rejected frames; saves the ~1 µs
260
+ // marshalling cost per skip.
261
+ sharedFrameCounter.value += 1;
262
+ const N = sharedEvalEveryN.value;
263
+ if (N > 1 && (sharedFrameCounter.value % N) !== 0)
264
+ return;
265
+ // Synthesise quaternion from accumulated yaw + pitch + roll.
266
+ // YPR Tait-Bryan order: q = q_yaw * q_pitch * q_roll. When
267
+ // roll=0 this reduces to the legacy 2-axis form (cy*sp, sy*cp,
268
+ // -sy*sp, cy*cp), so captures held level produce identical
269
+ // poses to the pre-F8.3-followup-roll behaviour. See the
270
+ // expanded math in the file header doc-comment.
271
+ const halfYaw = sharedYaw.value / 2;
272
+ const halfPitch = sharedPitch.value / 2;
273
+ const halfRoll = sharedRoll.value / 2;
274
+ const cy_ = Math.cos(halfYaw);
275
+ const sy_ = Math.sin(halfYaw);
276
+ const cp = Math.cos(halfPitch);
277
+ const sp = Math.sin(halfPitch);
278
+ const cr = Math.cos(halfRoll);
279
+ const sr = Math.sin(halfRoll);
280
+ const qx = cy_ * sp * cr + sy_ * cp * sr;
281
+ const qy = sy_ * cp * cr - cy_ * sp * sr;
282
+ const qz = cy_ * cp * sr - sy_ * sp * cr;
283
+ const qw = cy_ * cp * cr + sy_ * sp * sr;
284
+ // Intrinsics from FoV + actual frame dims.
285
+ // fx = w * (1 / (2 * tan(fovH/2))) (the parenthesised half
286
+ // is the precomputed `sharedFxNumerator` — see M1 fix).
287
+ const w = frame.width;
288
+ const h = frame.height;
289
+ const fx = w * sharedFxNumerator.value;
290
+ const fy = h * sharedFyNumerator.value;
291
+ plugin.call(frame, {
292
+ tx: 0, ty: 0, tz: 0,
293
+ qx, qy, qz, qw,
294
+ fx, fy,
295
+ cx: w / 2, cy: h / 2,
296
+ imageWidth: w, imageHeight: h,
297
+ timestampMs: 0,
298
+ // 2 == RNSARTrackingState.tracking — we always claim "good
299
+ // tracking" because there's no ARKit signal to differentiate
300
+ // (matches legacy useIncrementalJSDriver semantics).
301
+ trackingStateRaw: 2,
302
+ });
303
+ // Deps array intentionally minimal: only `plugin` actually
304
+ // requires worklet rebuild. All FoV / pose / counter / cadence
305
+ // values flow through stable shared-value refs that Reanimated
306
+ // wires through the producer-thread runtime independently of
307
+ // React's render cycle. (Adversarial-review M1.)
308
+ }, [plugin]);
309
+ // ── Return handle ───────────────────────────────────────────────
310
+ //
311
+ // Returns a getter for `isRunning` so callers always see the live
312
+ // state (the hook itself doesn't re-render on start/stop — that's
313
+ // intentional, avoids stale-Camera-prop churn).
314
+ return (0, react_1.useMemo)(() => ({
315
+ start,
316
+ stop,
317
+ frameProcessor: plugin != null ? frameProcessor : null,
318
+ get isRunning() { return isRunningRef.current; },
319
+ }), [start, stop, plugin, frameProcessor]);
320
+ }
321
+ //# sourceMappingURL=useFrameProcessorDriver.js.map
@@ -44,6 +44,11 @@ exports.useIncrementalJSDriver = useIncrementalJSDriver;
44
44
  const react_1 = require("react");
45
45
  const react_native_1 = require("react-native");
46
46
  const react_native_sensors_1 = require("react-native-sensors");
47
+ // One-shot deprecation flag — module-scoped so multiple host
48
+ // instances of the hook all share the same gate and we only emit
49
+ // the warning the first time anyone calls .start() in this
50
+ // process.
51
+ let deprecationWarningEmitted = false;
47
52
  function getNativeIncremental() {
48
53
  const m = react_native_1.NativeModules['IncrementalStitcher'];
49
54
  if (!m || typeof m !== 'object')
@@ -88,6 +93,22 @@ function useIncrementalJSDriver(options = {}) {
88
93
  // non-AR mode.
89
94
  if (isRunningRef.current)
90
95
  return;
96
+ // F8.5 — one-shot deprecation warning. v0.5.0 introduced
97
+ // `useFrameProcessorDriver` (vision-camera producer-thread
98
+ // path, native frame rate, no JPEG round-trip). The legacy
99
+ // takeSnapshot path stays available for one minor cycle to
100
+ // give hosts time to migrate, then is removed in v0.6.
101
+ if (!deprecationWarningEmitted) {
102
+ deprecationWarningEmitted = true;
103
+ // eslint-disable-next-line no-console
104
+ console.warn('[react-native-image-stitcher] `useIncrementalJSDriver` '
105
+ + 'is DEPRECATED as of v0.5.0 and will be REMOVED in '
106
+ + 'v0.6.0. Migrate to `useFrameProcessorDriver` (or '
107
+ + 'simply let `<Camera>` use its default driver — no host '
108
+ + 'code change needed). Opt-out via the `legacyDriver` '
109
+ + 'prop on `<Camera>` if you need to stay on the legacy '
110
+ + 'path temporarily.');
111
+ }
91
112
  const native = getNativeIncremental();
92
113
  if (!native)
93
114
  return;
package/ios/Package.swift CHANGED
@@ -1,4 +1,4 @@
1
- // swift-tools-version:5.9
1
+ // swift-tools-version:5.10
2
2
  //
3
3
  // Package.swift — SwiftPM manifest used **only for command-line testing**
4
4
  // of the algorithm layer (QualityChecker.swift). Production builds
@@ -38,26 +38,40 @@ let package = Package(
38
38
  .target(
39
39
  name: "RNImageStitcher",
40
40
  path: "Sources/RNImageStitcher",
41
- // Excluded from `swift test` because they depend on either
42
- // React (which isn't a SwiftPM dep) or OpenCV (which only
43
- // ships as an iOS XCFramework via the podspec — no macOS
44
- // build). The host app's CocoaPods workspace picks them up.
45
- exclude: [
46
- // React-dependent
47
- "QualityCheckerBridge.swift",
48
- "QualityCheckerBridge.m",
49
- "StitcherBridge.swift",
50
- "StitcherBridge.m",
51
- // OpenCV-dependent (Phase 2 stitcher)
52
- "OpenCVStitcher.h",
53
- "OpenCVStitcher.mm",
54
- // OpenCV-dependent (V16 Phase 1 keyframe collector)
55
- "OpenCVKeyframeCollector.h",
56
- "OpenCVKeyframeCollector.mm",
57
- // Stitcher.swift is `#if canImport(UIKit)`-gated so it
58
- // compiles to nothing on macOS; including it keeps the
59
- // file available to the Pods build without breaking
60
- // `swift test`.
41
+ // F8.3.H2-target instead of an `exclude` list (which broke
42
+ // every time a new .mm landed, e.g.
43
+ // `KeyframeGateFrameProcessor.mm` in F8.1, because SwiftPM
44
+ // still scans the directory and rejects "mixed language
45
+ // source files" if it sees both .swift and .mm), we use an
46
+ // explicit `sources` allowlist of files that compile cleanly
47
+ // on macOS (where `swift test` runs).
48
+ //
49
+ // What's in the allowlist:
50
+ // * QualityChecker.swift — Accelerate / CoreImage; macOS-OK.
51
+ // * KeyframeGate.swift Foundation + simd; macOS-OK.
52
+ //
53
+ // What's NOT (intentionally):
54
+ // * Anything with `import UIKit` / `import ARKit` — iOS only.
55
+ // CocoaPods compiles them for the host app via the podspec
56
+ // source_files glob; SwiftPM macOS doesn't need them.
57
+ // * .mm / .m / .h files — same. Picked up by CocoaPods.
58
+ // * RN-bridge Swift files (`*Bridge.swift`) `import React`,
59
+ // not a SwiftPM dep.
60
+ //
61
+ // The Frame Processor plugin's Swift⇄ObjC selector pin
62
+ // (formerly relied on by `FrameProcessorPluginSelectorTests`)
63
+ // is enforced as a compile-time `#selector(...)` reference
64
+ // inside `IncrementalStitcher.swift` itself — see the
65
+ // `_consumeFrameFromPluginSelectorPin` static. Drift breaks
66
+ // the SDK build, which is a stronger guarantee than a test
67
+ // that needs iOS-Simulator infrastructure to run.
68
+ sources: [
69
+ "QualityChecker.swift",
70
+ // KeyframeGate.swift depends on `KeyframeGateBridge` (ObjC
71
+ // class in .mm) and `RNSARFramePose` (from a UIKit-using
72
+ // Swift file), so it doesn't compile standalone under
73
+ // SwiftPM on macOS — only the CocoaPods build sees the
74
+ // full type graph.
61
75
  ]
62
76
  ),
63
77
  .testTarget(
@@ -362,6 +362,29 @@ public final class IncrementalStitcher: NSObject {
362
362
  private var hasFirstFrameTranslation: Bool = false
363
363
  private var consumeFrameCounter: Int = 0
364
364
 
365
+ /// F8.3 — gate for `consumeFrameFromPlugin` (the vision-camera
366
+ /// Frame Processor producer-thread entry point). TRUE only when
367
+ /// the current capture was started with
368
+ /// `frameSourceMode == "frameProcessor"`. In any other mode
369
+ /// (especially the legacy `"jsDriver"` path which feeds via
370
+ /// `processFrameAtPath`), the plugin would double-feed the
371
+ /// engine — pixel buffers from the producer thread + JPEG paths
372
+ /// from the JS interval, racing on the same workQueue — so we
373
+ /// drop the producer-thread call.
374
+ ///
375
+ /// Set under `stateLock` in `start()`, cleared under `stateLock`
376
+ /// in `cancel()` and `finalize()`, ALSO read under `stateLock`
377
+ /// from `consumeFrameFromPlugin`. The lock-protected read is
378
+ /// the simplest correctness story under Swift's
379
+ /// implementation-defined memory model — an earlier draft did an
380
+ /// unlocked read on the assumption "Bool loads are atomic on
381
+ /// arm64", but that's only true for the *instruction*, not for
382
+ /// compiler reordering / CSE if the property dispatch ever
383
+ /// changes from `@objc` (Obj-C dynamic, opaque to the optimiser)
384
+ /// to a Swift-only call (where the load could be hoisted).
385
+ /// Adversarial-review H1.
386
+ @objc public private(set) var frameProcessorIngestEnabled: Bool = false
387
+
365
388
  /// V16 — pose-driven keyframe gate. When `enabled` (set from the
366
389
  /// JS `frameSelectionMode = "pose-based"` config), each ARFrame is
367
390
  /// projected onto the latched ARKit plane and accepted only when
@@ -453,6 +476,13 @@ public final class IncrementalStitcher: NSObject {
453
476
 
454
477
  private override init() {
455
478
  super.init()
479
+ // F8.3.H2 — runtime check that Swift's auto-bridged ObjC
480
+ // selector for `consumeFrameFromPlugin(...)` matches the
481
+ // selector string the plugin's .mm dispatches. Asserts in
482
+ // dev builds; no-ops in release. See the
483
+ // `_consumeFrameFromPluginSelectorPin` declaration below for
484
+ // the full rationale.
485
+ IncrementalStitcher._verifyConsumeFrameFromPluginSelector()
456
486
  }
457
487
 
458
488
  /// 2026-05-18 (iOS cross-orientation fix) — bridge entry-point
@@ -916,6 +946,11 @@ public final class IncrementalStitcher: NSObject {
916
946
  self.batchKeyframeMode = false
917
947
  }
918
948
  self.isRunning = true
949
+ // F8.3 — enable the Frame Processor plugin's producer-thread
950
+ // ingest only for the new "frameProcessor" mode. Any other
951
+ // mode (arSession, jsDriver) keeps it OFF; see the ivar's
952
+ // declaration comment for why.
953
+ self.frameProcessorIngestEnabled = (frameSourceMode == "frameProcessor")
919
954
  self.snapshotJpegQuality = max(1, min(100, snapshotJpegQuality))
920
955
  self.snapshotEveryNAccepts = max(1, snapshotEveryNAccepts)
921
956
  self.acceptsSinceSnapshot = 0
@@ -1044,14 +1079,30 @@ public final class IncrementalStitcher: NSObject {
1044
1079
 
1045
1080
  stateLock.unlock()
1046
1081
 
1047
- // Register with the AR session only when running in the
1048
- // AR-frame-stream-driven mode. In jsDriver mode (iOS non-AR
1049
- // captures) the AR session is intentionally stopped so the
1050
- // vision-camera holds the camera; frames arrive via
1051
- // processFrameAtPath from JS instead. Registering as the
1052
- // consumer here would either crash (no running session) or
1053
- // mis-route frames once an AR session somewhere else came up.
1054
- if frameSourceMode != "jsDriver" {
1082
+ // Register with the AR session's consumer registry ONLY
1083
+ // for AR mode. Other modes don't need it:
1084
+ //
1085
+ // * `arSession` — REGISTER. ARKit's frame delegate
1086
+ // (RNSARSession.swift:572) calls
1087
+ // `consumer.consumeFrame(...)`.
1088
+ // * `frameProcessor` DO NOT register. The vision-
1089
+ // camera plugin calls us directly via
1090
+ // `consumeFrameFromPlugin`; we own
1091
+ // the camera, ARKit is intentionally
1092
+ // stopped. Registering here would
1093
+ // let any sibling code that briefly
1094
+ // starts an `ARSession` mid-capture
1095
+ // (analytics SDK, future "AR preview"
1096
+ // toggle, etc.) silently feed frames
1097
+ // in parallel with our producer-
1098
+ // thread plugin, racing on
1099
+ // `stateLock.try()` and corrupting
1100
+ // the gate's novelty math.
1101
+ // (Adversarial-review C1.)
1102
+ // * `jsDriver` — DO NOT register. Legacy path uses
1103
+ // `processFrameAtPath`; bypasses
1104
+ // consumeFrame entirely.
1105
+ if frameSourceMode == "arSession" {
1055
1106
  RNSARSession.shared.incrementalConsumer = self
1056
1107
  }
1057
1108
  }
@@ -1259,6 +1310,13 @@ public final class IncrementalStitcher: NSObject {
1259
1310
  self.keyframePaths = []
1260
1311
  self.keyframePoses = []
1261
1312
  self.isRunning = false
1313
+ // F8.3 — disable the Frame Processor plugin's producer-thread
1314
+ // ingest at the SAME lock-protected moment we flip isRunning,
1315
+ // so any in-flight producer-thread frame either sees both
1316
+ // (and proceeds with a now-doomed call that consumeFrame
1317
+ // drops via its own !isRunning guard) or sees neither (and
1318
+ // skips entirely).
1319
+ self.frameProcessorIngestEnabled = false
1262
1320
  let drops = self.droppedBackpressure
1263
1321
  stateLock.unlock()
1264
1322
 
@@ -2048,6 +2106,9 @@ public final class IncrementalStitcher: NSObject {
2048
2106
  self.keyframePaths = []
2049
2107
  self.keyframePoses = []
2050
2108
  self.isRunning = false
2109
+ // F8.3 — mirror the finalize() flip: cut producer-thread
2110
+ // ingest the moment we go !isRunning.
2111
+ self.frameProcessorIngestEnabled = false
2051
2112
  self.lastState = nil
2052
2113
  // V16 — reset the keyframe gate so the next start() begins
2053
2114
  // with a clean polygon state and counter. Safe to do under
@@ -3039,3 +3100,122 @@ public final class IncrementalStitcher: NSObject {
3039
3100
  }
3040
3101
 
3041
3102
  extension IncrementalStitcher: ARFrameConsumer {}
3103
+
3104
+ // MARK: - F8.3 — Frame Processor entry point
3105
+ //
3106
+ // `consumeFrameFromPlugin` is a thin @objc-compatible wrapper around
3107
+ // `consumeFrame(pixelBuffer:pose:)` that takes primitive args instead
3108
+ // of a `RNSARFramePose` instance. It exists so the
3109
+ // `KeyframeGateFrameProcessor.mm` plugin (ObjC++ producer-thread code)
3110
+ // can submit a frame without needing to construct a Swift class
3111
+ // across the bridging header.
3112
+ //
3113
+ // Threading: the worklet runs on vision-camera's producer thread
3114
+ // (NOT ARKit's delegate queue). Both threads ultimately serialise on
3115
+ // `consumeFrame`'s `stateLock.try()`, which is the documented
3116
+ // reentrancy boundary.
3117
+ //
3118
+ // In non-AR (Frame Processor) mode the caller supplies:
3119
+ // * `pixelBuffer` from `frame.buffer` (vision-camera YUV biplanar)
3120
+ // * `tx`/`ty`/`tz` = 0 (no AR translation; gyro only gives rotation)
3121
+ // * `qx,qy,qz,qw` from JS-thread gyro-integrated yaw+pitch (synthesised
3122
+ // as `q = q_yaw * q_pitch` — same convention as
3123
+ // `useIncrementalJSDriver`'s pose synthesis)
3124
+ // * `fx`/`fy` from frame dims + assumed FoV
3125
+ // * `cx`/`cy` at image centre
3126
+ // * `trackingStateRaw = 2` (= `.tracking`) — non-AR captures don't have
3127
+ // a real ARKit tracking-quality signal; reporting `.tracking` keeps
3128
+ // the engine's `trackingPoor` path inactive, matching the legacy
3129
+ // `useIncrementalJSDriver` contract.
3130
+ extension IncrementalStitcher {
3131
+ @objc public func consumeFrameFromPlugin(
3132
+ pixelBuffer: CVPixelBuffer,
3133
+ tx: Double, ty: Double, tz: Double,
3134
+ qx: Double, qy: Double, qz: Double, qw: Double,
3135
+ fx: Double, fy: Double, cx: Double, cy: Double,
3136
+ imageWidth: Int, imageHeight: Int,
3137
+ timestampMs: Double,
3138
+ trackingStateRaw: Int
3139
+ ) {
3140
+ // F8.3 — drop the call unless this capture was started in
3141
+ // frameProcessor mode. Read under stateLock so the producer
3142
+ // thread can't observe a stale TRUE during a cancel/finalize
3143
+ // teardown (adversarial-review H1). The lock-protected
3144
+ // read costs ~1 µs at producer-thread rate; negligible vs
3145
+ // the deep-copy that follows on accepts.
3146
+ stateLock.lock()
3147
+ let enabled = self.frameProcessorIngestEnabled
3148
+ stateLock.unlock()
3149
+ guard enabled else { return }
3150
+
3151
+ // Map the raw enum integer. Unknown values fall back to
3152
+ // `.notAvailable` so the engine's existing tracking-poor
3153
+ // branches catch them — failing CLOSED is safer than
3154
+ // silently claiming healthy tracking when the JS side sent
3155
+ // garbage (adversarial-review C2).
3156
+ let trackingState =
3157
+ RNSARTrackingState(rawValue: trackingStateRaw) ?? .notAvailable
3158
+ let pose = RNSARFramePose(
3159
+ tx: tx, ty: ty, tz: tz,
3160
+ qx: qx, qy: qy, qz: qz, qw: qw,
3161
+ fx: fx, fy: fy, cx: cx, cy: cy,
3162
+ imageWidth: imageWidth, imageHeight: imageHeight,
3163
+ timestampMs: timestampMs,
3164
+ trackingState: trackingState
3165
+ )
3166
+ consumeFrame(pixelBuffer: pixelBuffer, pose: pose)
3167
+ }
3168
+
3169
+ // F8.3.H2 — compile-time + runtime guard for the Swift⇄ObjC
3170
+ // selector contract that `KeyframeGateFrameProcessor.mm`
3171
+ // depends on.
3172
+ //
3173
+ // The .mm file forward-declares `IncrementalStitcher` and
3174
+ // dispatches `[shared consumeFrameFromPluginWithPixelBuffer:tx:
3175
+ // …:trackingStateRaw:]` by NAME — ObjC's late-binding means
3176
+ // signature drift would silently link but crash at runtime
3177
+ // with `NSInvalidArgumentException: unrecognized selector`
3178
+ // on the first non-AR frame.
3179
+ //
3180
+ // This `#selector(...)` reference forces the Swift compiler
3181
+ // to resolve the exact method signature. If anyone renames a
3182
+ // parameter label or adds/removes an argument, the
3183
+ // `_consumeFrameFromPluginSelectorPin` expression fails to
3184
+ // compile — the SDK won't build until the .mm's forward
3185
+ // declaration is updated to match. Stronger guarantee than a
3186
+ // test that needs iOS-Simulator infrastructure to run.
3187
+ //
3188
+ // The runtime check below additionally pins the exact
3189
+ // SELECTOR STRING the .mm dispatches. In dev/debug builds it
3190
+ // asserts; in release builds it's a no-op (the static let is
3191
+ // initialised lazily and never read otherwise, so the runtime
3192
+ // cost is one-time + tiny). Drift between Swift's auto-
3193
+ // generated selector name and the .mm's expected string
3194
+ // (e.g., if Swift's bridging rules change) trips the assert.
3195
+ private static let _consumeFrameFromPluginSelectorPin: Selector =
3196
+ #selector(IncrementalStitcher.consumeFrameFromPlugin(
3197
+ pixelBuffer:
3198
+ tx: ty: tz:
3199
+ qx: qy: qz: qw:
3200
+ fx: fy: cx: cy:
3201
+ imageWidth: imageHeight:
3202
+ timestampMs:
3203
+ trackingStateRaw:))
3204
+
3205
+ @inline(never)
3206
+ private static func _verifyConsumeFrameFromPluginSelector() {
3207
+ let expected =
3208
+ "consumeFrameFromPluginWithPixelBuffer:tx:ty:tz:"
3209
+ + "qx:qy:qz:qw:fx:fy:cx:cy:"
3210
+ + "imageWidth:imageHeight:timestampMs:trackingStateRaw:"
3211
+ let actual = NSStringFromSelector(_consumeFrameFromPluginSelectorPin)
3212
+ assert(
3213
+ actual == expected,
3214
+ "Frame Processor selector drift — Swift's auto-bridged "
3215
+ + "ObjC selector for consumeFrameFromPlugin is "
3216
+ + "\(actual) but KeyframeGateFrameProcessor.mm's "
3217
+ + "forward declaration expects \(expected). Update the "
3218
+ + ".mm to match (or fix the assumption here).",
3219
+ )
3220
+ }
3221
+ }