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.
- package/CHANGELOG.md +165 -0
- package/README.md +1 -0
- package/android/build.gradle +33 -0
- package/android/src/main/java/io/imagestitcher/rn/CvFlowGateFrameProcessor.kt +163 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +148 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +431 -23
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +65 -7
- package/dist/camera/Camera.d.ts +68 -1
- package/dist/camera/Camera.js +102 -16
- package/dist/camera/CameraView.d.ts +17 -5
- package/dist/camera/CameraView.js +28 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -1
- package/dist/stitching/incremental.d.ts +13 -4
- package/dist/stitching/useFrameProcessorDriver.d.ts +148 -0
- package/dist/stitching/useFrameProcessorDriver.js +321 -0
- package/dist/stitching/useIncrementalJSDriver.js +21 -0
- package/ios/Package.swift +35 -21
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +188 -8
- package/ios/Sources/RNImageStitcher/KeyframeGateFrameProcessor.mm +196 -0
- package/package.json +3 -1
- package/src/camera/Camera.tsx +190 -15
- package/src/camera/CameraView.tsx +50 -0
- package/src/index.ts +12 -0
- package/src/stitching/incremental.ts +12 -3
- package/src/stitching/useFrameProcessorDriver.ts +407 -0
- package/src/stitching/useIncrementalJSDriver.ts +24 -0
|
@@ -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.
|
|
3
|
+
"version": "0.5.1",
|
|
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
|
}
|
package/src/camera/Camera.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
|
@@ -221,6 +235,29 @@ export interface CameraProps {
|
|
|
221
235
|
showSettingsButton?: boolean;
|
|
222
236
|
style?: StyleProp<ViewStyle>;
|
|
223
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Which incremental stitcher engine to drive. Default
|
|
240
|
+
* `'batch-keyframe'` — collects accepted JPEGs and runs
|
|
241
|
+
* `cv::Stitcher` once at finalize time. This is the v0.4+
|
|
242
|
+
* production default and what the v0.5 Frame Processor migration
|
|
243
|
+
* exercises.
|
|
244
|
+
*
|
|
245
|
+
* Switch to a live engine (`'firstwins-rectilinear'` or
|
|
246
|
+
* `'hybrid'`) for low-latency in-flight stitching. Live engines
|
|
247
|
+
* exercise the F8.6 pixel-buffer ingest path (skipping the JPEG
|
|
248
|
+
* encode/decode round-trip; ~30–50 ms saved per accept) when the
|
|
249
|
+
* Frame Processor driver is active.
|
|
250
|
+
*
|
|
251
|
+
* See `docs/f8-frame-processor-plan.md` and the v0.5.0
|
|
252
|
+
* CHANGELOG for the trade-offs between batch-keyframe and live
|
|
253
|
+
* engines.
|
|
254
|
+
*/
|
|
255
|
+
engine?: 'batch-keyframe'
|
|
256
|
+
| 'hybrid'
|
|
257
|
+
| 'slitscan-rotate' | 'slitscan-both'
|
|
258
|
+
| 'firstwins' | 'firstwins-zoomed' | 'firstwins-rectilinear'
|
|
259
|
+
| 'slitscan';
|
|
260
|
+
|
|
224
261
|
/**
|
|
225
262
|
* Optional destination directory for captures. When set, the lib
|
|
226
263
|
* lands tap-photos at `${outputDir}/photo-${ts}.jpg` and panoramas
|
|
@@ -256,6 +293,47 @@ export interface CameraProps {
|
|
|
256
293
|
onLensChange?: (lens: CameraLens) => void;
|
|
257
294
|
onFramesDropped?: (info: FramesDroppedInfo) => void;
|
|
258
295
|
onError?: (err: CameraError) => void;
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Optional vision-camera frame processor. Only attached to the
|
|
299
|
+
* non-AR preview (AR mode uses ARCameraView, which doesn't expose
|
|
300
|
+
* a worklet seam). Build the worklet on the host side with
|
|
301
|
+
* `useFrameProcessor` from `react-native-vision-camera`.
|
|
302
|
+
*
|
|
303
|
+
* Introduced for F8 (FrameProcessor port) — see
|
|
304
|
+
* `docs/f8-frame-processor-plan.md`.
|
|
305
|
+
*
|
|
306
|
+
* As of v0.5 (F8.3) this prop is **deprecated for the standard
|
|
307
|
+
* non-AR capture flow**: the SDK now installs its own frame
|
|
308
|
+
* processor via `useFrameProcessorDriver` that pipes pixel
|
|
309
|
+
* buffers into the incremental stitcher with synthesised pose.
|
|
310
|
+
* Setting this prop in the default mode will be IGNORED with a
|
|
311
|
+
* one-time console.warn — supplying your own worklet would race
|
|
312
|
+
* with the SDK's pixel-buffer feed.
|
|
313
|
+
*
|
|
314
|
+
* Three coexistence rules:
|
|
315
|
+
* * Default (modern non-AR): SDK owns the worklet, this prop
|
|
316
|
+
* is ignored.
|
|
317
|
+
* * `legacyDriver={true}`: SDK uses the old `useIncrementalJSDriver`
|
|
318
|
+
* (takeSnapshot path). Honoured for diagnostics or as an
|
|
319
|
+
* escape hatch.
|
|
320
|
+
* * AR mode: vision-camera Camera isn't mounted, this prop is
|
|
321
|
+
* irrelevant.
|
|
322
|
+
*/
|
|
323
|
+
frameProcessor?: ReadonlyFrameProcessor | DrawableFrameProcessor;
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Opt back into the legacy `useIncrementalJSDriver` for non-AR
|
|
327
|
+
* captures (the v0.4 path: `takeSnapshot` → JPEG → cache file →
|
|
328
|
+
* `IncrementalStitcher.processFrameAtPath`).
|
|
329
|
+
*
|
|
330
|
+
* Default `false` (use the new `useFrameProcessorDriver`, which
|
|
331
|
+
* runs the gate on the camera producer thread at native frame
|
|
332
|
+
* rate via a vision-camera Frame Processor plugin). The legacy
|
|
333
|
+
* path will be removed in v0.6 — set this only if you hit a
|
|
334
|
+
* specific issue with the new driver and need to ship a fix.
|
|
335
|
+
*/
|
|
336
|
+
legacyDriver?: boolean;
|
|
259
337
|
}
|
|
260
338
|
|
|
261
339
|
|
|
@@ -530,6 +608,9 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
530
608
|
onLensChange,
|
|
531
609
|
onFramesDropped,
|
|
532
610
|
onError,
|
|
611
|
+
frameProcessor: hostFrameProcessor,
|
|
612
|
+
legacyDriver = false,
|
|
613
|
+
engine = 'batch-keyframe',
|
|
533
614
|
} = props;
|
|
534
615
|
|
|
535
616
|
const insets = useSafeAreaInsets();
|
|
@@ -727,10 +808,45 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
727
808
|
// imperative pattern (start on hold-start, stop on hold-end) avoids
|
|
728
809
|
// the re-render churn entirely.
|
|
729
810
|
const jsDriver = useIncrementalJSDriver();
|
|
730
|
-
//
|
|
811
|
+
// F8.3 — vision-camera Frame Processor variant. Always
|
|
812
|
+
// instantiated so we don't have conditional hook calls; only one
|
|
813
|
+
// of the two drivers actually .start()s per capture. Stop() on
|
|
814
|
+
// an idle driver is a no-op.
|
|
815
|
+
const fpDriver = useFrameProcessorDriver();
|
|
816
|
+
// Safety: ensure both drivers are stopped if the component unmounts
|
|
731
817
|
// mid-recording. Empty deps so this only fires on unmount.
|
|
732
818
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
733
|
-
useEffect(() => () => { jsDriver.stop(); }, []);
|
|
819
|
+
useEffect(() => () => { jsDriver.stop(); fpDriver.stop(); }, []);
|
|
820
|
+
|
|
821
|
+
// F8.3 — one-shot deprecation warning when the host supplies their
|
|
822
|
+
// own `frameProcessor` while running in the default (Frame
|
|
823
|
+
// Processor driver) mode. Two worklets racing on the same
|
|
824
|
+
// producer thread would corrupt the engine's workQueue ordering;
|
|
825
|
+
// the SDK's own worklet wins and the host's is ignored. Hosts
|
|
826
|
+
// that *need* a custom worklet must opt into `legacyDriver={true}`
|
|
827
|
+
// (which switches off the SDK's worklet entirely).
|
|
828
|
+
const hostFrameProcessorIgnoredWarnedRef = useRef(false);
|
|
829
|
+
if (
|
|
830
|
+
hostFrameProcessor != null
|
|
831
|
+
&& !legacyDriver
|
|
832
|
+
&& !hostFrameProcessorIgnoredWarnedRef.current
|
|
833
|
+
) {
|
|
834
|
+
hostFrameProcessorIgnoredWarnedRef.current = true;
|
|
835
|
+
// eslint-disable-next-line no-console
|
|
836
|
+
console.warn(
|
|
837
|
+
'[react-native-image-stitcher] The `frameProcessor` prop on '
|
|
838
|
+
+ '<Camera> is ignored when the default driver is active '
|
|
839
|
+
+ '(legacyDriver=false). Either remove the prop or set '
|
|
840
|
+
+ 'legacyDriver={true} to opt into the legacy path.',
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
// The Frame Processor worklet actually bound to vision-camera's
|
|
844
|
+
// Camera. Resolution order:
|
|
845
|
+
// 1. Legacy mode: honor the host's prop (or null).
|
|
846
|
+
// 2. Modern mode: SDK driver's worklet, regardless of host's prop.
|
|
847
|
+
const effectiveFrameProcessor = legacyDriver
|
|
848
|
+
? (hostFrameProcessor ?? null)
|
|
849
|
+
: fpDriver.frameProcessor;
|
|
734
850
|
|
|
735
851
|
// ── Subscribe to engine state for live keyframe thumbs ──────────
|
|
736
852
|
useEffect(() => {
|
|
@@ -787,6 +903,17 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
787
903
|
const accepted = incrementalState?.acceptedCount ?? 0;
|
|
788
904
|
if (accepted > lastAcceptedCountRef.current) {
|
|
789
905
|
lastAcceptedCountRef.current = accepted;
|
|
906
|
+
// F8.3 review-of-review (M3 revert): originally gated this to
|
|
907
|
+
// `legacyDriver` because the Frame Processor driver doesn't
|
|
908
|
+
// consult `imuGate` for its own pose synthesis. That ignored a
|
|
909
|
+
// load-bearing side effect: `imuGate.resetAnchor()` bounds the
|
|
910
|
+
// IIR-integrator drift window per-accept, and
|
|
911
|
+
// `imuGate.getTotalAbsMetres()` is read at finalize time
|
|
912
|
+
// (Camera.tsx:1097) as `imuTranslationMetres` into the native
|
|
913
|
+
// stitchMode auto-resolver (PANORAMA vs SCANS). Without the
|
|
914
|
+
// per-accept reset, long FP-driver captures let IIR drift
|
|
915
|
+
// compound → inflated metres → biased toward SCANS. Keep the
|
|
916
|
+
// reset firing for ALL non-AR modes.
|
|
790
917
|
if (isNonAR) {
|
|
791
918
|
imuGate.resetAnchor();
|
|
792
919
|
}
|
|
@@ -917,26 +1044,43 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
917
1044
|
snapshotEveryNAccepts: 1,
|
|
918
1045
|
frameRotationDegrees: orientationRotation,
|
|
919
1046
|
captureOrientation: deviceOrientation,
|
|
920
|
-
|
|
1047
|
+
// F8.3 — non-AR captures pick between the new Frame Processor
|
|
1048
|
+
// driver (default) and the legacy JS-snapshot driver (opt-in
|
|
1049
|
+
// via `legacyDriver={true}`). AR captures always use the
|
|
1050
|
+
// ARSession-driven path.
|
|
1051
|
+
frameSourceMode: isNonAR
|
|
1052
|
+
? (legacyDriver ? 'jsDriver' : 'frameProcessor')
|
|
1053
|
+
: 'arSession',
|
|
921
1054
|
composeWidth: 1920,
|
|
922
1055
|
composeHeight: 1080,
|
|
923
1056
|
canvasWidth: 5000,
|
|
924
1057
|
canvasHeight: 5000,
|
|
925
|
-
engine
|
|
1058
|
+
engine,
|
|
926
1059
|
config: panoramaSettingsToNativeConfig({
|
|
927
1060
|
...settings,
|
|
928
1061
|
captureSource: effectiveCaptureSource,
|
|
929
1062
|
}),
|
|
930
1063
|
});
|
|
1064
|
+
// F8.3 review-of-review (M3 revert): `imuGate.resetAnchor()`
|
|
1065
|
+
// is load-bearing for the stitchMode auto-resolver (see the
|
|
1066
|
+
// matching comment on the per-accept reset useEffect above).
|
|
1067
|
+
// Keep firing it on every capture start, not just legacy mode.
|
|
931
1068
|
imuGate.resetAnchor();
|
|
932
|
-
// Start
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
//
|
|
1069
|
+
// Start the non-AR frame source. AR mode feeds natively from
|
|
1070
|
+
// ARSession so both drivers stay idle in that path.
|
|
1071
|
+
// * Default: Frame Processor driver — worklet runs on the
|
|
1072
|
+
// producer thread, plugin calls `consumeFrameFromPlugin`
|
|
1073
|
+
// directly. No camera ref needed (vision-camera owns it).
|
|
1074
|
+
// * Legacy: JS driver — `takeSnapshot` + `processFrameAtPath`
|
|
1075
|
+
// via the cameraRef.
|
|
1076
|
+
// Imperative-pattern rationale: see the useIncrementalJSDriver
|
|
1077
|
+
// comment above re. why this isn't a useEffect.
|
|
938
1078
|
if (isNonAR) {
|
|
939
|
-
|
|
1079
|
+
if (legacyDriver) {
|
|
1080
|
+
jsDriver.start(visionCameraRef);
|
|
1081
|
+
} else {
|
|
1082
|
+
fpDriver.start();
|
|
1083
|
+
}
|
|
940
1084
|
}
|
|
941
1085
|
} catch (err) {
|
|
942
1086
|
setStatusPhase('idle');
|
|
@@ -957,16 +1101,22 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
957
1101
|
effectiveCaptureSource,
|
|
958
1102
|
imuGate,
|
|
959
1103
|
jsDriver,
|
|
1104
|
+
fpDriver,
|
|
1105
|
+
legacyDriver,
|
|
1106
|
+
engine,
|
|
960
1107
|
onError,
|
|
961
1108
|
]);
|
|
962
1109
|
|
|
963
1110
|
const handleHoldEnd = useCallback(async () => {
|
|
964
1111
|
if (statusPhase !== 'recording') return;
|
|
965
1112
|
setStatusPhase('stitching');
|
|
966
|
-
// Stop pumping new
|
|
967
|
-
// racing the final cv::Stitcher pass against late-arriving
|
|
968
|
-
//
|
|
1113
|
+
// Stop pumping new frames before finalizing so the engine isn't
|
|
1114
|
+
// racing the final cv::Stitcher pass against late-arriving
|
|
1115
|
+
// keyframes. Both stop() calls are no-ops when the
|
|
1116
|
+
// corresponding driver wasn't started (AR mode, or the inactive
|
|
1117
|
+
// driver in non-AR mode).
|
|
969
1118
|
jsDriver.stop();
|
|
1119
|
+
fpDriver.stop();
|
|
970
1120
|
try {
|
|
971
1121
|
// Compose the panorama output path: host-controlled if
|
|
972
1122
|
// `outputDir` is set, else the lib's canonical capture dir
|
|
@@ -1044,6 +1194,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1044
1194
|
onError,
|
|
1045
1195
|
recordingStartedAt,
|
|
1046
1196
|
jsDriver,
|
|
1197
|
+
fpDriver,
|
|
1047
1198
|
// F10 Phase 2 review N1 — these four were missing pre-fix. The
|
|
1048
1199
|
// callback reads `settings.debug` (to gate the stitchToast),
|
|
1049
1200
|
// `isNonAR` (to decide whether to read IMU totalAbs translation),
|
|
@@ -1101,6 +1252,30 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1101
1252
|
video
|
|
1102
1253
|
flash="off"
|
|
1103
1254
|
style={StyleSheet.absoluteFill}
|
|
1255
|
+
// F8 (FrameProcessor port) — host-supplied worklet runs on
|
|
1256
|
+
// the camera producer thread for every frame. Only wired
|
|
1257
|
+
// in non-AR mode; AR mode uses ARCameraView which doesn't
|
|
1258
|
+
// expose a frame-processor seam. See
|
|
1259
|
+
// docs/f8-frame-processor-plan.md.
|
|
1260
|
+
cameraProps={effectiveFrameProcessor != null
|
|
1261
|
+
? { frameProcessor: effectiveFrameProcessor }
|
|
1262
|
+
: undefined}
|
|
1263
|
+
onError={(err) => {
|
|
1264
|
+
// CameraView already filters known transient lifecycle
|
|
1265
|
+
// errors (screen-lock, etc.) before invoking this. What
|
|
1266
|
+
// reaches here is a real vision-camera runtime issue:
|
|
1267
|
+
// pull `code`/`message` defensively (the type is
|
|
1268
|
+
// `unknown` from CameraView's perspective) and wrap in
|
|
1269
|
+
// a SDK-typed `CameraError` so hosts get a stable shape.
|
|
1270
|
+
const e = err as { code?: string; message?: string };
|
|
1271
|
+
const codeStr = e?.code ?? 'unknown';
|
|
1272
|
+
const msg = e?.message ?? String(err);
|
|
1273
|
+
onError?.(new CameraError(
|
|
1274
|
+
'VISION_CAMERA_RUNTIME',
|
|
1275
|
+
`${codeStr}: ${msg}`,
|
|
1276
|
+
err,
|
|
1277
|
+
));
|
|
1278
|
+
}}
|
|
1104
1279
|
/>
|
|
1105
1280
|
)}
|
|
1106
1281
|
|
|
@@ -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
|
-
*
|
|
208
|
-
*
|
|
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). */
|