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.
- package/CHANGELOG.md +108 -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/IncrementalStitcher.kt +214 -0
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +65 -7
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +137 -124
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +212 -119
- package/dist/camera/Camera.d.ts +50 -1
- package/dist/camera/Camera.js +100 -15
- 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/Sources/RNImageStitcher/IncrementalStitcher.swift +128 -8
- package/ios/Sources/RNImageStitcher/KeyframeGateFrameProcessor.mm +196 -0
- package/package.json +3 -1
- package/src/camera/Camera.tsx +164 -14
- 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
|
@@ -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
|
|
1048
|
-
// AR
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
//
|
|
1054
|
-
|
|
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.
|
|
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
|
}
|
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
|
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
//
|
|
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
|
-
|
|
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
|
|
967
|
-
// racing the final cv::Stitcher pass against late-arriving
|
|
968
|
-
//
|
|
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
|
|