react-native-image-stitcher 0.4.1 → 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.
@@ -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.1",
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
 
@@ -60,6 +60,24 @@ export interface CameraViewProps {
60
60
  * preferences (focus-on-tap vs. tap-to-lock).
61
61
  */
62
62
  onPreviewTap?: (event: { x: number; y: number }) => void;
63
+
64
+ /**
65
+ * Forwarded from vision-camera's `<Camera onError>` AFTER lifecycle
66
+ * errors are filtered. The SDK's built-in filter swallows:
67
+ *
68
+ * * `system/camera-is-restricted` — screen-lock / DoNotDisturb
69
+ * temporarily revokes camera access; vision-camera re-acquires
70
+ * on resume. Logged to console.warn, NOT surfaced.
71
+ * * `system/camera-has-been-disconnected` — another app grabbed
72
+ * the camera. Same auto-recovery.
73
+ * * `device/camera-already-in-use` — same class as above.
74
+ *
75
+ * Real errors (permission denials, hardware failures, malformed
76
+ * format requests) are forwarded. Hosts can therefore safely
77
+ * pipe this to a redbox / Crashlytics without getting paged on
78
+ * routine screen-lock events.
79
+ */
80
+ onError?: (error: unknown) => void;
63
81
  }
64
82
 
65
83
 
@@ -68,6 +86,19 @@ export interface CameraViewProps {
68
86
  * to callers (so ``cameraRef.current.takePhoto()`` keeps working),
69
87
  * while presenting a smaller API on the outside.
70
88
  */
89
+ // Error codes vision-camera reports for transient lifecycle events.
90
+ // Filtered out of the SDK's onError forward (see `handleVcError` in
91
+ // the body): the camera self-recovers when the device comes back into
92
+ // the foreground / regains permission / the other app releases the
93
+ // device. Surfacing these as host errors causes spurious crash
94
+ // reports during routine phone-lock / app-switch operations.
95
+ const VC_LIFECYCLE_ERROR_CODES: ReadonlySet<string> = new Set([
96
+ 'system/camera-is-restricted', // screen lock, DoNotDisturb, MDM policy
97
+ 'system/camera-has-been-disconnected', // another app grabbed the camera
98
+ 'device/camera-already-in-use', // same class as above
99
+ ]);
100
+
101
+
71
102
  export const CameraView = forwardRef<Camera | null, CameraViewProps>(function CameraView(
72
103
  {
73
104
  device,
@@ -77,9 +108,27 @@ export const CameraView = forwardRef<Camera | null, CameraViewProps>(function Ca
77
108
  guidance,
78
109
  style,
79
110
  cameraProps,
111
+ onError,
80
112
  },
81
113
  ref,
82
114
  ): React.JSX.Element {
115
+ // Error filter — see `VC_LIFECYCLE_ERROR_CODES` for the swallow
116
+ // list rationale. `code` on vision-camera's `CameraRuntimeError`
117
+ // is typed as a string; treat any non-string defensively as a
118
+ // "forward it" so we don't accidentally swallow unknown errors.
119
+ const handleVcError = (err: unknown): void => {
120
+ const code = (err as { code?: unknown })?.code;
121
+ if (typeof code === 'string' && VC_LIFECYCLE_ERROR_CODES.has(code)) {
122
+ // eslint-disable-next-line no-console
123
+ console.warn(
124
+ '[react-native-image-stitcher] vision-camera reported a '
125
+ + `transient lifecycle error (${code}); the camera will `
126
+ + 'auto-recover on resume. Not forwarding to onError.',
127
+ );
128
+ return;
129
+ }
130
+ onError?.(err);
131
+ };
83
132
  // Internal ref so we can both attach to <Camera> and forward outward.
84
133
  const innerRef = useRef<Camera>(null);
85
134
  useImperativeHandle(ref, () => innerRef.current as Camera);
@@ -112,6 +161,7 @@ export const CameraView = forwardRef<Camera | null, CameraViewProps>(function Ca
112
161
  // "what you see is what was taken".
113
162
  outputOrientation="device"
114
163
  torch={flash === 'on' ? 'on' : 'off'}
164
+ onError={handleVcError}
115
165
  {...cameraProps}
116
166
  />
117
167
  {guidance ? (
package/src/index.ts CHANGED
@@ -178,6 +178,18 @@ export {
178
178
  export type { IncrementalState } from './stitching/incremental';
179
179
  export { useIncrementalStitcher } from './stitching/useIncrementalStitcher';
180
180
  export { useIncrementalJSDriver } from './stitching/useIncrementalJSDriver';
181
+ export type {
182
+ UseIncrementalJSDriverOptions,
183
+ IncrementalJSDriverHandle,
184
+ } from './stitching/useIncrementalJSDriver';
185
+ // F8.3 — vision-camera Frame Processor variant of the non-AR
186
+ // driver. Preferred over `useIncrementalJSDriver` in v0.5+; the
187
+ // JS driver stays exported as a deprecated fallback until v0.6.
188
+ export { useFrameProcessorDriver } from './stitching/useFrameProcessorDriver';
189
+ export type {
190
+ UseFrameProcessorDriverOptions,
191
+ FrameProcessorDriverHandle,
192
+ } from './stitching/useFrameProcessorDriver';
181
193
 
182
194
  // ── Batch stitching ───────────────────────────────────────────────────
183
195
  // Feed a video file straight to OpenCV's cv::Stitcher, bypassing the
@@ -203,11 +203,20 @@ export interface IncrementalStartOptions {
203
203
  * - 'jsDriver' — engine skips AR-session registration; JS
204
204
  * feeds frames via `processFrameAtPath`. Use in iOS non-AR
205
205
  * captures (vision-camera + gyro). No AR session required.
206
+ * LEGACY; deprecated in v0.5, removed in v0.6.
206
207
  *
207
- * Android ignores this option its engine always accepts
208
- * JS-driven frames.
208
+ * - 'frameProcessor' (F8.3 iOS / F8.4 Android, v0.5+) engine
209
+ * flips on `frameProcessorIngestEnabled` so the vision-camera
210
+ * Frame Processor plugin (`cv_flow_gate_process_frame`) can
211
+ * feed pixel data directly into the engine's gate path. iOS
212
+ * passes the `CVPixelBuffer` straight to `consumeFrame`;
213
+ * Android extracts the Y plane to a ByteArray and encodes
214
+ * accepted frames to JPEG inline (the platform-specific
215
+ * engine-input divergence is tracked as F8.6). Use in non-AR
216
+ * captures driven by `useFrameProcessorDriver`. Pairs with
217
+ * `Camera`'s default driver mode.
209
218
  */
210
- frameSourceMode?: 'arSession' | 'jsDriver';
219
+ frameSourceMode?: 'arSession' | 'jsDriver' | 'frameProcessor';
211
220
  /** Compose-resolution width in pixels (default 720 for portrait, 960 for landscape). */
212
221
  composeWidth?: number;
213
222
  /** Compose-resolution height in pixels (default 960 for portrait, 720 for landscape). */