react-native-image-stitcher 0.4.0 → 0.5.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.
@@ -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
@@ -916,6 +939,11 @@ public final class IncrementalStitcher: NSObject {
916
939
  self.batchKeyframeMode = false
917
940
  }
918
941
  self.isRunning = true
942
+ // F8.3 — enable the Frame Processor plugin's producer-thread
943
+ // ingest only for the new "frameProcessor" mode. Any other
944
+ // mode (arSession, jsDriver) keeps it OFF; see the ivar's
945
+ // declaration comment for why.
946
+ self.frameProcessorIngestEnabled = (frameSourceMode == "frameProcessor")
919
947
  self.snapshotJpegQuality = max(1, min(100, snapshotJpegQuality))
920
948
  self.snapshotEveryNAccepts = max(1, snapshotEveryNAccepts)
921
949
  self.acceptsSinceSnapshot = 0
@@ -1044,14 +1072,30 @@ public final class IncrementalStitcher: NSObject {
1044
1072
 
1045
1073
  stateLock.unlock()
1046
1074
 
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" {
1075
+ // Register with the AR session's consumer registry ONLY
1076
+ // for AR mode. Other modes don't need it:
1077
+ //
1078
+ // * `arSession` — REGISTER. ARKit's frame delegate
1079
+ // (RNSARSession.swift:572) calls
1080
+ // `consumer.consumeFrame(...)`.
1081
+ // * `frameProcessor` DO NOT register. The vision-
1082
+ // camera plugin calls us directly via
1083
+ // `consumeFrameFromPlugin`; we own
1084
+ // the camera, ARKit is intentionally
1085
+ // stopped. Registering here would
1086
+ // let any sibling code that briefly
1087
+ // starts an `ARSession` mid-capture
1088
+ // (analytics SDK, future "AR preview"
1089
+ // toggle, etc.) silently feed frames
1090
+ // in parallel with our producer-
1091
+ // thread plugin, racing on
1092
+ // `stateLock.try()` and corrupting
1093
+ // the gate's novelty math.
1094
+ // (Adversarial-review C1.)
1095
+ // * `jsDriver` — DO NOT register. Legacy path uses
1096
+ // `processFrameAtPath`; bypasses
1097
+ // consumeFrame entirely.
1098
+ if frameSourceMode == "arSession" {
1055
1099
  RNSARSession.shared.incrementalConsumer = self
1056
1100
  }
1057
1101
  }
@@ -1259,6 +1303,13 @@ public final class IncrementalStitcher: NSObject {
1259
1303
  self.keyframePaths = []
1260
1304
  self.keyframePoses = []
1261
1305
  self.isRunning = false
1306
+ // F8.3 — disable the Frame Processor plugin's producer-thread
1307
+ // ingest at the SAME lock-protected moment we flip isRunning,
1308
+ // so any in-flight producer-thread frame either sees both
1309
+ // (and proceeds with a now-doomed call that consumeFrame
1310
+ // drops via its own !isRunning guard) or sees neither (and
1311
+ // skips entirely).
1312
+ self.frameProcessorIngestEnabled = false
1262
1313
  let drops = self.droppedBackpressure
1263
1314
  stateLock.unlock()
1264
1315
 
@@ -2048,6 +2099,9 @@ public final class IncrementalStitcher: NSObject {
2048
2099
  self.keyframePaths = []
2049
2100
  self.keyframePoses = []
2050
2101
  self.isRunning = false
2102
+ // F8.3 — mirror the finalize() flip: cut producer-thread
2103
+ // ingest the moment we go !isRunning.
2104
+ self.frameProcessorIngestEnabled = false
2051
2105
  self.lastState = nil
2052
2106
  // V16 — reset the keyframe gate so the next start() begins
2053
2107
  // with a clean polygon state and counter. Safe to do under
@@ -3039,3 +3093,69 @@ public final class IncrementalStitcher: NSObject {
3039
3093
  }
3040
3094
 
3041
3095
  extension IncrementalStitcher: ARFrameConsumer {}
3096
+
3097
+ // MARK: - F8.3 — Frame Processor entry point
3098
+ //
3099
+ // `consumeFrameFromPlugin` is a thin @objc-compatible wrapper around
3100
+ // `consumeFrame(pixelBuffer:pose:)` that takes primitive args instead
3101
+ // of a `RNSARFramePose` instance. It exists so the
3102
+ // `KeyframeGateFrameProcessor.mm` plugin (ObjC++ producer-thread code)
3103
+ // can submit a frame without needing to construct a Swift class
3104
+ // across the bridging header.
3105
+ //
3106
+ // Threading: the worklet runs on vision-camera's producer thread
3107
+ // (NOT ARKit's delegate queue). Both threads ultimately serialise on
3108
+ // `consumeFrame`'s `stateLock.try()`, which is the documented
3109
+ // reentrancy boundary.
3110
+ //
3111
+ // In non-AR (Frame Processor) mode the caller supplies:
3112
+ // * `pixelBuffer` from `frame.buffer` (vision-camera YUV biplanar)
3113
+ // * `tx`/`ty`/`tz` = 0 (no AR translation; gyro only gives rotation)
3114
+ // * `qx,qy,qz,qw` from JS-thread gyro-integrated yaw+pitch (synthesised
3115
+ // as `q = q_yaw * q_pitch` — same convention as
3116
+ // `useIncrementalJSDriver`'s pose synthesis)
3117
+ // * `fx`/`fy` from frame dims + assumed FoV
3118
+ // * `cx`/`cy` at image centre
3119
+ // * `trackingStateRaw = 2` (= `.tracking`) — non-AR captures don't have
3120
+ // a real ARKit tracking-quality signal; reporting `.tracking` keeps
3121
+ // the engine's `trackingPoor` path inactive, matching the legacy
3122
+ // `useIncrementalJSDriver` contract.
3123
+ extension IncrementalStitcher {
3124
+ @objc public func consumeFrameFromPlugin(
3125
+ pixelBuffer: CVPixelBuffer,
3126
+ tx: Double, ty: Double, tz: Double,
3127
+ qx: Double, qy: Double, qz: Double, qw: Double,
3128
+ fx: Double, fy: Double, cx: Double, cy: Double,
3129
+ imageWidth: Int, imageHeight: Int,
3130
+ timestampMs: Double,
3131
+ trackingStateRaw: Int
3132
+ ) {
3133
+ // F8.3 — drop the call unless this capture was started in
3134
+ // frameProcessor mode. Read under stateLock so the producer
3135
+ // thread can't observe a stale TRUE during a cancel/finalize
3136
+ // teardown (adversarial-review H1). The lock-protected
3137
+ // read costs ~1 µs at producer-thread rate; negligible vs
3138
+ // the deep-copy that follows on accepts.
3139
+ stateLock.lock()
3140
+ let enabled = self.frameProcessorIngestEnabled
3141
+ stateLock.unlock()
3142
+ guard enabled else { return }
3143
+
3144
+ // Map the raw enum integer. Unknown values fall back to
3145
+ // `.notAvailable` so the engine's existing tracking-poor
3146
+ // branches catch them — failing CLOSED is safer than
3147
+ // silently claiming healthy tracking when the JS side sent
3148
+ // garbage (adversarial-review C2).
3149
+ let trackingState =
3150
+ RNSARTrackingState(rawValue: trackingStateRaw) ?? .notAvailable
3151
+ let pose = RNSARFramePose(
3152
+ tx: tx, ty: ty, tz: tz,
3153
+ qx: qx, qy: qy, qz: qz, qw: qw,
3154
+ fx: fx, fy: fy, cx: cx, cy: cy,
3155
+ imageWidth: imageWidth, imageHeight: imageHeight,
3156
+ timestampMs: timestampMs,
3157
+ trackingState: trackingState
3158
+ )
3159
+ consumeFrame(pixelBuffer: pixelBuffer, pose: pose)
3160
+ }
3161
+ }
@@ -0,0 +1,196 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // KeyframeGateFrameProcessor.mm — F8.3 vision-camera Frame Processor
4
+ // plugin: a thin pose-injector that hands every producer-thread frame
5
+ // to `IncrementalStitcher.consumeFrameFromPlugin`.
6
+ //
7
+ // JS-side usage (from a worklet):
8
+ //
9
+ // import { VisionCameraProxy, useFrameProcessor } from
10
+ // 'react-native-vision-camera';
11
+ //
12
+ // const plugin = VisionCameraProxy.initFrameProcessorPlugin(
13
+ // 'cv_flow_gate_process_frame', {},
14
+ // );
15
+ //
16
+ // const fp = useFrameProcessor((frame) => {
17
+ // 'worklet';
18
+ // if (plugin == null) return;
19
+ // plugin.call(frame, {
20
+ // qx, qy, qz, qw, // gyro-integrated quaternion
21
+ // fx, fy, cx, cy, // synthesised intrinsics
22
+ // imageWidth, imageHeight,
23
+ // // tx/ty/tz default to 0 (no AR translation in non-AR mode)
24
+ // // trackingStateRaw default = 2 (= .tracking)
25
+ // });
26
+ // }, [plugin]);
27
+ //
28
+ // F8.3 SCOPE — the plugin owns NO gate state and NO per-frame
29
+ // decision logic. It just:
30
+ // 1. Extracts `CVPixelBuffer` from the vision-camera frame.
31
+ // 2. Builds a pose from the worklet's `arguments` dict (with
32
+ // defaults safe for non-AR mode).
33
+ // 3. Calls `[IncrementalStitcher.shared consumeFrameFromPlugin:…]`
34
+ // which routes into the SAME entry point AR mode uses
35
+ // (`consumeFrame(pixelBuffer:pose:)`).
36
+ //
37
+ // The KeyframeGate evaluation, work-queue dispatch, deep-copy, and
38
+ // engine ingest all happen INSIDE `consumeFrame` — exactly as they
39
+ // already do for AR mode. Single source of truth, no duplication.
40
+ //
41
+ // CONDITIONAL COMPILATION — this file imports vision-camera headers.
42
+ // The SDK's podspec does NOT declare a Pod dependency on VisionCamera
43
+ // because we don't want non-camera-using consumers to be forced to
44
+ // pull it. The `__has_include` guard means: if the consumer's pod
45
+ // install pulled vision-camera (which it will, since `<Camera>`
46
+ // requires it as a peer dep), this plugin compiles in. Otherwise the
47
+ // file is a no-op translation unit.
48
+
49
+ #import <Foundation/Foundation.h>
50
+
51
+ #if __has_include(<VisionCamera/FrameProcessorPlugin.h>)
52
+
53
+ #import <VisionCamera/Frame.h>
54
+ #import <VisionCamera/FrameProcessorPlugin.h>
55
+ #import <VisionCamera/FrameProcessorPluginRegistry.h>
56
+ #import <VisionCamera/VisionCameraProxyHolder.h>
57
+ #import <CoreVideo/CoreVideo.h>
58
+
59
+ // Forward-declare only the Swift APIs we use here. Importing the
60
+ // full `RNImageStitcher-Swift.h` would force this TU to also import
61
+ // React (`RCTEventEmitter`, `RCTViewManager`) and ARKit
62
+ // (`ARSessionDelegate`), because the generated header exposes every
63
+ // `@objc` symbol in the module. We don't need any of those.
64
+ //
65
+ // Risk: this declaration must stay in sync with the Swift extension
66
+ // at the bottom of `IncrementalStitcher.swift`. Both files are
67
+ // committed together; signature drift would be caught at link time
68
+ // (unresolved selector) and at the next build.
69
+ @class IncrementalStitcher;
70
+ @interface IncrementalStitcher : NSObject
71
+ + (IncrementalStitcher * _Nonnull)shared;
72
+ - (void)consumeFrameFromPluginWithPixelBuffer:(CVPixelBufferRef _Nonnull)pixelBuffer
73
+ tx:(double)tx
74
+ ty:(double)ty
75
+ tz:(double)tz
76
+ qx:(double)qx
77
+ qy:(double)qy
78
+ qz:(double)qz
79
+ qw:(double)qw
80
+ fx:(double)fx
81
+ fy:(double)fy
82
+ cx:(double)cx
83
+ cy:(double)cy
84
+ imageWidth:(NSInteger)imageWidth
85
+ imageHeight:(NSInteger)imageHeight
86
+ timestampMs:(double)timestampMs
87
+ trackingStateRaw:(NSInteger)trackingStateRaw;
88
+ @end
89
+
90
+ // Read a Double from the per-call `arguments` dict with a default.
91
+ // Used to extract pose params; tolerant of missing keys (non-AR mode
92
+ // may send only the rotation fields, not translation/intrinsics).
93
+ static double kg_argDouble(NSDictionary* args, NSString* key, double defaultValue) {
94
+ if (args == nil) return defaultValue;
95
+ NSNumber* n = args[key];
96
+ return [n isKindOfClass:[NSNumber class]] ? n.doubleValue : defaultValue;
97
+ }
98
+ static NSInteger kg_argInt(NSDictionary* args, NSString* key, NSInteger defaultValue) {
99
+ if (args == nil) return defaultValue;
100
+ NSNumber* n = args[key];
101
+ return [n isKindOfClass:[NSNumber class]] ? n.integerValue : defaultValue;
102
+ }
103
+
104
+ @interface KeyframeGateFrameProcessor : FrameProcessorPlugin
105
+ @end
106
+
107
+ @implementation KeyframeGateFrameProcessor
108
+
109
+ - (instancetype)initWithProxy:(VisionCameraProxyHolder*)proxy
110
+ withOptions:(NSDictionary* _Nullable)options {
111
+ // No per-instance setup. All gate tunables (overlapThreshold,
112
+ // maxCount, flow params, strategy, ...) live on
113
+ // `IncrementalStitcher` and are configured at its `start()` time
114
+ // from the host-app settings. The plugin is a stateless
115
+ // pose-injector.
116
+ return [super initWithProxy:proxy withOptions:options];
117
+ }
118
+
119
+ - (id)callback:(Frame*)frame withArguments:(NSDictionary* _Nullable)arguments {
120
+ CMSampleBufferRef sampleBuffer = frame.buffer;
121
+ if (sampleBuffer == NULL) {
122
+ return @{@"submitted": @NO, @"error": @"no sample buffer"};
123
+ }
124
+
125
+ CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
126
+ if (pixelBuffer == NULL) {
127
+ return @{@"submitted": @NO, @"error": @"no pixel buffer"};
128
+ }
129
+
130
+ // Frame dims for the pose. Read from plane 0 if planar (YUV) else
131
+ // whole buffer; this is the dimensionality the stitcher expects.
132
+ size_t planeCount = CVPixelBufferGetPlaneCount(pixelBuffer);
133
+ NSInteger width = (NSInteger)(planeCount >= 1
134
+ ? CVPixelBufferGetWidthOfPlane(pixelBuffer, 0)
135
+ : CVPixelBufferGetWidth(pixelBuffer));
136
+ NSInteger height = (NSInteger)(planeCount >= 1
137
+ ? CVPixelBufferGetHeightOfPlane(pixelBuffer, 0)
138
+ : CVPixelBufferGetHeight(pixelBuffer));
139
+
140
+ // Pose from worklet args. Defaults are safe non-AR values:
141
+ // * tx/ty/tz = 0 (no translation in non-AR; gyro only gives rot)
142
+ // * qw = 1 (identity quaternion if JS hasn't supplied rotation)
143
+ // * fx/fy/cx/cy = 0 → JS-driver caller MUST supply these (the
144
+ // engine derives FoV from intrinsics; 0 would yield NaN FoV).
145
+ // We default the principal point to image centre as a safer
146
+ // fallback if only fx/fy are missing.
147
+ // * trackingStateRaw = 2 → `.tracking` (non-AR captures don't
148
+ // have a real tracking-quality signal; engine's `trackingPoor`
149
+ // path stays inactive, matching legacy `useIncrementalJSDriver`).
150
+ double tx = kg_argDouble(arguments, @"tx", 0.0);
151
+ double ty = kg_argDouble(arguments, @"ty", 0.0);
152
+ double tz = kg_argDouble(arguments, @"tz", 0.0);
153
+ double qx = kg_argDouble(arguments, @"qx", 0.0);
154
+ double qy = kg_argDouble(arguments, @"qy", 0.0);
155
+ double qz = kg_argDouble(arguments, @"qz", 0.0);
156
+ double qw = kg_argDouble(arguments, @"qw", 1.0);
157
+ double fx = kg_argDouble(arguments, @"fx", 0.0);
158
+ double fy = kg_argDouble(arguments, @"fy", 0.0);
159
+ double cx = kg_argDouble(arguments, @"cx", (double)width / 2.0);
160
+ double cy = kg_argDouble(arguments, @"cy", (double)height / 2.0);
161
+ double timestampMs = kg_argDouble(arguments, @"timestampMs", 0.0);
162
+ NSInteger trackingState = kg_argInt(arguments, @"trackingStateRaw", 2);
163
+
164
+ // Submit. consumeFrame internally early-returns if isRunning ==
165
+ // false, so it's safe to call every producer-thread frame whether
166
+ // or not a capture is in progress. ~1-2 µs of overhead per
167
+ // "stitcher not running" frame; negligible at 30 fps.
168
+ [IncrementalStitcher.shared
169
+ consumeFrameFromPluginWithPixelBuffer:pixelBuffer
170
+ tx:tx ty:ty tz:tz
171
+ qx:qx qy:qy qz:qz qw:qw
172
+ fx:fx fy:fy cx:cx cy:cy
173
+ imageWidth:width
174
+ imageHeight:height
175
+ timestampMs:timestampMs
176
+ trackingStateRaw:trackingState];
177
+
178
+ return @{@"submitted": @YES};
179
+ }
180
+
181
+ // Auto-register the plugin at class-load time. Name must match what
182
+ // JS passes to `VisionCameraProxy.initFrameProcessorPlugin(...)`.
183
+ + (void)load {
184
+ [FrameProcessorPluginRegistry
185
+ addFrameProcessorPlugin:@"cv_flow_gate_process_frame"
186
+ withInitializer:^FrameProcessorPlugin* _Nonnull(
187
+ VisionCameraProxyHolder* proxy,
188
+ NSDictionary* _Nullable options) {
189
+ return [[KeyframeGateFrameProcessor alloc]
190
+ initWithProxy:proxy withOptions:options];
191
+ }];
192
+ }
193
+
194
+ @end
195
+
196
+ #endif // __has_include(<VisionCamera/FrameProcessorPlugin.h>)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -61,6 +61,7 @@
61
61
  "react-native-safe-area-context": "^4.0.0",
62
62
  "react-native-sensors": "^7.0.0",
63
63
  "react-native-vision-camera": "^4.0.0",
64
+ "react-native-worklets-core": "^1.3.0",
64
65
  "rxjs": "^7.0.0",
65
66
  "ts-jest": "^29.1.0",
66
67
  "typescript": "^5.5.0"
@@ -69,6 +70,7 @@
69
70
  "react": ">=18.0.0",
70
71
  "react-native": ">=0.72.0",
71
72
  "react-native-vision-camera": ">=4.7.0",
73
+ "react-native-worklets-core": ">=1.3.0",
72
74
  "react-native-sensors": ">=7.0.0",
73
75
  "react-native-safe-area-context": ">=4.0.0"
74
76
  }
@@ -56,7 +56,11 @@ import {
56
56
  type ViewStyle,
57
57
  } from 'react-native';
58
58
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
59
- import type { Camera as VisionCamera } from 'react-native-vision-camera';
59
+ import type {
60
+ Camera as VisionCamera,
61
+ DrawableFrameProcessor,
62
+ ReadonlyFrameProcessor,
63
+ } from 'react-native-vision-camera';
60
64
 
61
65
  import { useARSession } from '../ar/useARSession';
62
66
  import { ARCameraView, type ARCameraViewHandle } from './ARCameraView';
@@ -86,6 +90,7 @@ import {
86
90
  type IncrementalState,
87
91
  } from '../stitching/incremental';
88
92
  import { useIncrementalJSDriver } from '../stitching/useIncrementalJSDriver';
93
+ import { useFrameProcessorDriver } from '../stitching/useFrameProcessorDriver';
89
94
  import { useIncrementalStitcher } from '../stitching/useIncrementalStitcher';
90
95
  import { useIMUTranslationGate } from '../sensors/useIMUTranslationGate';
91
96
  import { toBareFilePath, toFileUri } from '../utils/paths';
@@ -164,6 +169,15 @@ export type CameraErrorCode =
164
169
  | 'STITCH_CAMERA_PARAMS_FAIL'
165
170
  | 'STITCH_OOM'
166
171
  | 'OUTPUT_WRITE_FAILED'
172
+ /**
173
+ * Vision-camera surfaced a runtime error that isn't a known
174
+ * transient lifecycle event (those are swallowed inside the SDK's
175
+ * `<CameraView>`). Examples that DO reach the host as this code:
176
+ * `format/invalid-format`, `capture/recording-canceled`,
177
+ * `device/microphone-permission-denied`, ... The full error
178
+ * object is on `.cause` for inspection.
179
+ */
180
+ | 'VISION_CAMERA_RUNTIME'
167
181
  | 'UNKNOWN';
168
182
 
169
183
 
@@ -256,6 +270,47 @@ export interface CameraProps {
256
270
  onLensChange?: (lens: CameraLens) => void;
257
271
  onFramesDropped?: (info: FramesDroppedInfo) => void;
258
272
  onError?: (err: CameraError) => void;
273
+
274
+ /**
275
+ * Optional vision-camera frame processor. Only attached to the
276
+ * non-AR preview (AR mode uses ARCameraView, which doesn't expose
277
+ * a worklet seam). Build the worklet on the host side with
278
+ * `useFrameProcessor` from `react-native-vision-camera`.
279
+ *
280
+ * Introduced for F8 (FrameProcessor port) — see
281
+ * `docs/f8-frame-processor-plan.md`.
282
+ *
283
+ * As of v0.5 (F8.3) this prop is **deprecated for the standard
284
+ * non-AR capture flow**: the SDK now installs its own frame
285
+ * processor via `useFrameProcessorDriver` that pipes pixel
286
+ * buffers into the incremental stitcher with synthesised pose.
287
+ * Setting this prop in the default mode will be IGNORED with a
288
+ * one-time console.warn — supplying your own worklet would race
289
+ * with the SDK's pixel-buffer feed.
290
+ *
291
+ * Three coexistence rules:
292
+ * * Default (modern non-AR): SDK owns the worklet, this prop
293
+ * is ignored.
294
+ * * `legacyDriver={true}`: SDK uses the old `useIncrementalJSDriver`
295
+ * (takeSnapshot path). Honoured for diagnostics or as an
296
+ * escape hatch.
297
+ * * AR mode: vision-camera Camera isn't mounted, this prop is
298
+ * irrelevant.
299
+ */
300
+ frameProcessor?: ReadonlyFrameProcessor | DrawableFrameProcessor;
301
+
302
+ /**
303
+ * Opt back into the legacy `useIncrementalJSDriver` for non-AR
304
+ * captures (the v0.4 path: `takeSnapshot` → JPEG → cache file →
305
+ * `IncrementalStitcher.processFrameAtPath`).
306
+ *
307
+ * Default `false` (use the new `useFrameProcessorDriver`, which
308
+ * runs the gate on the camera producer thread at native frame
309
+ * rate via a vision-camera Frame Processor plugin). The legacy
310
+ * path will be removed in v0.6 — set this only if you hit a
311
+ * specific issue with the new driver and need to ship a fix.
312
+ */
313
+ legacyDriver?: boolean;
259
314
  }
260
315
 
261
316
 
@@ -530,6 +585,8 @@ export function Camera(props: CameraProps): React.JSX.Element {
530
585
  onLensChange,
531
586
  onFramesDropped,
532
587
  onError,
588
+ frameProcessor: hostFrameProcessor,
589
+ legacyDriver = false,
533
590
  } = props;
534
591
 
535
592
  const insets = useSafeAreaInsets();
@@ -727,10 +784,45 @@ export function Camera(props: CameraProps): React.JSX.Element {
727
784
  // imperative pattern (start on hold-start, stop on hold-end) avoids
728
785
  // the re-render churn entirely.
729
786
  const jsDriver = useIncrementalJSDriver();
730
- // Safety: ensure the driver is stopped if the component unmounts
787
+ // F8.3 vision-camera Frame Processor variant. Always
788
+ // instantiated so we don't have conditional hook calls; only one
789
+ // of the two drivers actually .start()s per capture. Stop() on
790
+ // an idle driver is a no-op.
791
+ const fpDriver = useFrameProcessorDriver();
792
+ // Safety: ensure both drivers are stopped if the component unmounts
731
793
  // mid-recording. Empty deps so this only fires on unmount.
732
794
  // eslint-disable-next-line react-hooks/exhaustive-deps
733
- useEffect(() => () => { jsDriver.stop(); }, []);
795
+ useEffect(() => () => { jsDriver.stop(); fpDriver.stop(); }, []);
796
+
797
+ // F8.3 — one-shot deprecation warning when the host supplies their
798
+ // own `frameProcessor` while running in the default (Frame
799
+ // Processor driver) mode. Two worklets racing on the same
800
+ // producer thread would corrupt the engine's workQueue ordering;
801
+ // the SDK's own worklet wins and the host's is ignored. Hosts
802
+ // that *need* a custom worklet must opt into `legacyDriver={true}`
803
+ // (which switches off the SDK's worklet entirely).
804
+ const hostFrameProcessorIgnoredWarnedRef = useRef(false);
805
+ if (
806
+ hostFrameProcessor != null
807
+ && !legacyDriver
808
+ && !hostFrameProcessorIgnoredWarnedRef.current
809
+ ) {
810
+ hostFrameProcessorIgnoredWarnedRef.current = true;
811
+ // eslint-disable-next-line no-console
812
+ console.warn(
813
+ '[react-native-image-stitcher] The `frameProcessor` prop on '
814
+ + '<Camera> is ignored when the default driver is active '
815
+ + '(legacyDriver=false). Either remove the prop or set '
816
+ + 'legacyDriver={true} to opt into the legacy path.',
817
+ );
818
+ }
819
+ // The Frame Processor worklet actually bound to vision-camera's
820
+ // Camera. Resolution order:
821
+ // 1. Legacy mode: honor the host's prop (or null).
822
+ // 2. Modern mode: SDK driver's worklet, regardless of host's prop.
823
+ const effectiveFrameProcessor = legacyDriver
824
+ ? (hostFrameProcessor ?? null)
825
+ : fpDriver.frameProcessor;
734
826
 
735
827
  // ── Subscribe to engine state for live keyframe thumbs ──────────
736
828
  useEffect(() => {
@@ -787,6 +879,17 @@ export function Camera(props: CameraProps): React.JSX.Element {
787
879
  const accepted = incrementalState?.acceptedCount ?? 0;
788
880
  if (accepted > lastAcceptedCountRef.current) {
789
881
  lastAcceptedCountRef.current = accepted;
882
+ // F8.3 review-of-review (M3 revert): originally gated this to
883
+ // `legacyDriver` because the Frame Processor driver doesn't
884
+ // consult `imuGate` for its own pose synthesis. That ignored a
885
+ // load-bearing side effect: `imuGate.resetAnchor()` bounds the
886
+ // IIR-integrator drift window per-accept, and
887
+ // `imuGate.getTotalAbsMetres()` is read at finalize time
888
+ // (Camera.tsx:1097) as `imuTranslationMetres` into the native
889
+ // stitchMode auto-resolver (PANORAMA vs SCANS). Without the
890
+ // per-accept reset, long FP-driver captures let IIR drift
891
+ // compound → inflated metres → biased toward SCANS. Keep the
892
+ // reset firing for ALL non-AR modes.
790
893
  if (isNonAR) {
791
894
  imuGate.resetAnchor();
792
895
  }
@@ -917,7 +1020,13 @@ export function Camera(props: CameraProps): React.JSX.Element {
917
1020
  snapshotEveryNAccepts: 1,
918
1021
  frameRotationDegrees: orientationRotation,
919
1022
  captureOrientation: deviceOrientation,
920
- frameSourceMode: isNonAR ? 'jsDriver' : 'arSession',
1023
+ // F8.3 non-AR captures pick between the new Frame Processor
1024
+ // driver (default) and the legacy JS-snapshot driver (opt-in
1025
+ // via `legacyDriver={true}`). AR captures always use the
1026
+ // ARSession-driven path.
1027
+ frameSourceMode: isNonAR
1028
+ ? (legacyDriver ? 'jsDriver' : 'frameProcessor')
1029
+ : 'arSession',
921
1030
  composeWidth: 1920,
922
1031
  composeHeight: 1080,
923
1032
  canvasWidth: 5000,
@@ -928,15 +1037,26 @@ export function Camera(props: CameraProps): React.JSX.Element {
928
1037
  captureSource: effectiveCaptureSource,
929
1038
  }),
930
1039
  });
1040
+ // F8.3 review-of-review (M3 revert): `imuGate.resetAnchor()`
1041
+ // is load-bearing for the stitchMode auto-resolver (see the
1042
+ // matching comment on the per-accept reset useEffect above).
1043
+ // Keep firing it on every capture start, not just legacy mode.
931
1044
  imuGate.resetAnchor();
932
- // Start pumping vision-camera snapshots into the engine for
933
- // non-AR captures. AR mode feeds frames natively from the
934
- // ARSession, so the JS driver stays idle in that path. This
935
- // mirrors AuditCaptureScreen.handleHoldStart's `androidDriver.start`
936
- // imperative call see the comment near `useIncrementalJSDriver`
937
- // for why this is NOT done via useEffect.
1045
+ // Start the non-AR frame source. AR mode feeds natively from
1046
+ // ARSession so both drivers stay idle in that path.
1047
+ // * Default: Frame Processor driver worklet runs on the
1048
+ // producer thread, plugin calls `consumeFrameFromPlugin`
1049
+ // directly. No camera ref needed (vision-camera owns it).
1050
+ // * Legacy: JS driver `takeSnapshot` + `processFrameAtPath`
1051
+ // via the cameraRef.
1052
+ // Imperative-pattern rationale: see the useIncrementalJSDriver
1053
+ // comment above re. why this isn't a useEffect.
938
1054
  if (isNonAR) {
939
- jsDriver.start(visionCameraRef);
1055
+ if (legacyDriver) {
1056
+ jsDriver.start(visionCameraRef);
1057
+ } else {
1058
+ fpDriver.start();
1059
+ }
940
1060
  }
941
1061
  } catch (err) {
942
1062
  setStatusPhase('idle');
@@ -957,16 +1077,21 @@ export function Camera(props: CameraProps): React.JSX.Element {
957
1077
  effectiveCaptureSource,
958
1078
  imuGate,
959
1079
  jsDriver,
1080
+ fpDriver,
1081
+ legacyDriver,
960
1082
  onError,
961
1083
  ]);
962
1084
 
963
1085
  const handleHoldEnd = useCallback(async () => {
964
1086
  if (statusPhase !== 'recording') return;
965
1087
  setStatusPhase('stitching');
966
- // Stop pumping new snapshots before finalizing so the engine isn't
967
- // racing the final cv::Stitcher pass against late-arriving keyframes.
968
- // No-op in AR mode where jsDriver was never started.
1088
+ // Stop pumping new frames before finalizing so the engine isn't
1089
+ // racing the final cv::Stitcher pass against late-arriving
1090
+ // keyframes. Both stop() calls are no-ops when the
1091
+ // corresponding driver wasn't started (AR mode, or the inactive
1092
+ // driver in non-AR mode).
969
1093
  jsDriver.stop();
1094
+ fpDriver.stop();
970
1095
  try {
971
1096
  // Compose the panorama output path: host-controlled if
972
1097
  // `outputDir` is set, else the lib's canonical capture dir
@@ -1044,6 +1169,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
1044
1169
  onError,
1045
1170
  recordingStartedAt,
1046
1171
  jsDriver,
1172
+ fpDriver,
1047
1173
  // F10 Phase 2 review N1 — these four were missing pre-fix. The
1048
1174
  // callback reads `settings.debug` (to gate the stitchToast),
1049
1175
  // `isNonAR` (to decide whether to read IMU totalAbs translation),
@@ -1101,6 +1227,30 @@ export function Camera(props: CameraProps): React.JSX.Element {
1101
1227
  video
1102
1228
  flash="off"
1103
1229
  style={StyleSheet.absoluteFill}
1230
+ // F8 (FrameProcessor port) — host-supplied worklet runs on
1231
+ // the camera producer thread for every frame. Only wired
1232
+ // in non-AR mode; AR mode uses ARCameraView which doesn't
1233
+ // expose a frame-processor seam. See
1234
+ // docs/f8-frame-processor-plan.md.
1235
+ cameraProps={effectiveFrameProcessor != null
1236
+ ? { frameProcessor: effectiveFrameProcessor }
1237
+ : undefined}
1238
+ onError={(err) => {
1239
+ // CameraView already filters known transient lifecycle
1240
+ // errors (screen-lock, etc.) before invoking this. What
1241
+ // reaches here is a real vision-camera runtime issue:
1242
+ // pull `code`/`message` defensively (the type is
1243
+ // `unknown` from CameraView's perspective) and wrap in
1244
+ // a SDK-typed `CameraError` so hosts get a stable shape.
1245
+ const e = err as { code?: string; message?: string };
1246
+ const codeStr = e?.code ?? 'unknown';
1247
+ const msg = e?.message ?? String(err);
1248
+ onError?.(new CameraError(
1249
+ 'VISION_CAMERA_RUNTIME',
1250
+ `${codeStr}: ${msg}`,
1251
+ err,
1252
+ ));
1253
+ }}
1104
1254
  />
1105
1255
  )}
1106
1256