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.
- package/CHANGELOG.md +83 -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/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
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
16
16
|
|
|
17
17
|
## [Unreleased]
|
|
18
18
|
|
|
19
|
+
## [0.5.0] — 2026-05-25
|
|
20
|
+
|
|
21
|
+
### Added — F8 Frame Processor port
|
|
22
|
+
|
|
23
|
+
`<Camera>` now drives **non-AR captures through a vision-camera
|
|
24
|
+
Frame Processor** on the camera producer thread instead of the
|
|
25
|
+
4 Hz `takeSnapshot` → JPEG → cache-file path the v0.4 series used.
|
|
26
|
+
|
|
27
|
+
- **`useFrameProcessorDriver`** (`src/stitching/useFrameProcessorDriver.ts`)
|
|
28
|
+
— new hook with the same `{ start, stop, frameProcessor,
|
|
29
|
+
isRunning }` shape as the legacy `useIncrementalJSDriver`. Gyro
|
|
30
|
+
yaw / pitch / **roll** are integrated on the JS thread and
|
|
31
|
+
published via `useSharedValue` so the worklet reads pose
|
|
32
|
+
zero-hop. Plugin acquisition uses a mount-once + 16 ms
|
|
33
|
+
setTimeout retry pattern to side-step the vision-camera
|
|
34
|
+
registry init race.
|
|
35
|
+
- **`cv_flow_gate_process_frame` JSI plugin** — registered on both
|
|
36
|
+
platforms:
|
|
37
|
+
- iOS: `ios/Sources/RNImageStitcher/KeyframeGateFrameProcessor.mm`
|
|
38
|
+
+ `@objc IncrementalStitcher.consumeFrameFromPlugin(...)`
|
|
39
|
+
wrapper. `CVPixelBuffer` flows end-to-end into
|
|
40
|
+
`IncrementalStitcher.consumeFrame` — the SAME entry point AR
|
|
41
|
+
mode already uses. Zero JPEG round-trip on accept.
|
|
42
|
+
- Android: `android/src/main/java/io/imagestitcher/rn/CvFlowGateFrameProcessor.kt`
|
|
43
|
+
+ Kotlin `consumeFrameFromPlugin(...)` wrapper. Extracts the
|
|
44
|
+
Y plane on the producer thread, encodes inline JPEG on accept
|
|
45
|
+
via the existing `YuvImageConverter`, hands the path to
|
|
46
|
+
`ingestFromARCameraView`. Pixel-buffer parity tracked as F8.6.
|
|
47
|
+
- **`frameSourceMode: 'frameProcessor'`** in
|
|
48
|
+
`IncrementalStitcher.start()` options — flips
|
|
49
|
+
`frameProcessorIngestEnabled` ON so the plugin's producer-thread
|
|
50
|
+
feed reaches the engine. Default for non-AR captures from v0.5.
|
|
51
|
+
- **`legacyDriver?: boolean`** prop on `<Camera>` — opt-in escape
|
|
52
|
+
hatch back to `useIncrementalJSDriver` for hosts that hit a
|
|
53
|
+
vision-camera incompatibility. Will be removed in v0.6.
|
|
54
|
+
- **`VISION_CAMERA_RUNTIME` error code** for vision-camera
|
|
55
|
+
runtime errors that aren't transient lifecycle events.
|
|
56
|
+
- **Roll axis** (gyro-Z) in the synthesised pose quaternion —
|
|
57
|
+
`q = q_yaw * q_pitch * q_roll`. Field captures with wrist-twist
|
|
58
|
+
no longer lie to the cv::Stitcher's intrinsic estimator.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- Default non-AR driver is now `useFrameProcessorDriver`. Hosts
|
|
63
|
+
using `<Camera>` opt in transparently — no code change needed
|
|
64
|
+
unless you want the legacy path (`legacyDriver={true}`).
|
|
65
|
+
- `host-supplied frameProcessor` prop on `<Camera>` is now treated
|
|
66
|
+
as a legacy escape hatch: silently overridden by the SDK driver
|
|
67
|
+
in default mode with a one-shot `console.warn`.
|
|
68
|
+
|
|
69
|
+
### Deprecated
|
|
70
|
+
|
|
71
|
+
- **`useIncrementalJSDriver`** — works through v0.5, removed in
|
|
72
|
+
v0.6. Hosts that drove non-AR captures with this hook should
|
|
73
|
+
migrate to letting `<Camera>` do it by default
|
|
74
|
+
(`legacyDriver` unset). The hook now emits a one-shot
|
|
75
|
+
`console.warn` from its `start()` call.
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
- **Vision-camera transient lifecycle errors** (screen-lock,
|
|
80
|
+
app-switch, DoNotDisturb, MDM camera restriction) are now
|
|
81
|
+
filtered inside `<CameraView>` instead of propagating to the
|
|
82
|
+
host's `onError`. Auto-recovery happens on resume; hosts no
|
|
83
|
+
longer get spurious crash reports on every phone-lock.
|
|
84
|
+
|
|
85
|
+
### Added — peer dependency
|
|
86
|
+
|
|
87
|
+
- **`react-native-worklets-core`** is now a declared peer
|
|
88
|
+
dependency (`>=1.3.0`). It was already required transitively
|
|
89
|
+
via `react-native-vision-camera@^4`; the explicit declaration
|
|
90
|
+
documents the contract.
|
|
91
|
+
|
|
92
|
+
### Tracking — known follow-ups (don't gate this release)
|
|
93
|
+
|
|
94
|
+
- **F8.6** — Android engine refactor for pixel-buffer-direct
|
|
95
|
+
ingest (true zero-copy parity with iOS).
|
|
96
|
+
- **F8.3.H2-target** — `swift test` currently can't run the
|
|
97
|
+
iOS test target due to mixed Swift/.mm sources. The
|
|
98
|
+
`FrameProcessorPluginSelectorTests` selector guard is in place
|
|
99
|
+
as a documentation artifact; CI test-runner fix is a separate
|
|
100
|
+
task.
|
|
101
|
+
|
|
19
102
|
## [0.4.1] — 2026-05-23
|
|
20
103
|
|
|
21
104
|
### Fixed
|
package/README.md
CHANGED
|
@@ -160,6 +160,7 @@ The component owns the runtime state; the parent persists across launches via th
|
|
|
160
160
|
| **Android namespace** | `io.imagestitcher.rn`. |
|
|
161
161
|
| **Stitching pipeline** | Shared C++ under `cpp/stitcher.cpp` invoked from both iOS Obj-C++ and Android JNI. PANORAMA + SCANS modes; C+D progressive-confidence retry over keyframes. |
|
|
162
162
|
| **Two capture-source paths** | AR uses ARKit (iOS) / ARCore (Android) pose stream. Non-AR uses vision-camera + IMU integration via `useIMUTranslationGate`. |
|
|
163
|
+
| **Frame Processor driver (v0.5+)** | Non-AR captures evaluate the keyframe gate on the camera producer thread at native frame rate via a vision-camera Frame Processor (`cv_flow_gate_process_frame`). iOS passes `CVPixelBuffer` end-to-end; Android writes a Y-plane-derived JPEG on accept. Opt-out via `<Camera legacyDriver />` for one minor cycle. See `docs/f8-frame-processor-plan.md` for the design. |
|
|
163
164
|
| **Two supported pan modes** | Landscape phone + vertical pan; portrait phone + horizontal pan. Any other combination is a user deviation, not a supported mode. |
|
|
164
165
|
|
|
165
166
|
## License
|
package/android/build.gradle
CHANGED
|
@@ -215,6 +215,39 @@ dependencies {
|
|
|
215
215
|
// on demand (~30 MB) the first time the user opens an AR
|
|
216
216
|
// capture screen on a supported device.
|
|
217
217
|
implementation "com.google.ar:core:1.45.0"
|
|
218
|
+
|
|
219
|
+
// F8.4 — vision-camera as a compile-time peer dep. Same
|
|
220
|
+
// `compileOnly` pattern as React Native above: host apps that
|
|
221
|
+
// wire `<Camera>` already include the autolinked
|
|
222
|
+
// `:react-native-vision-camera` Gradle project; we just need
|
|
223
|
+
// `com.mrousavy.camera.frameprocessors.*` types on the compile
|
|
224
|
+
// classpath to build `CvFlowGateFrameProcessor.kt` + the
|
|
225
|
+
// registration in `RNImageStitcherPackage`. Runtime
|
|
226
|
+
// resolution is the host's responsibility —
|
|
227
|
+
// `RNImageStitcherPackage`'s static initialiser catches
|
|
228
|
+
// `NoClassDefFoundError` so the SDK still loads if a non-
|
|
229
|
+
// camera consumer omits the dep.
|
|
230
|
+
//
|
|
231
|
+
// `findProject(...)` guard: lets the SDK still compile in
|
|
232
|
+
// hosts that haven't installed react-native-vision-camera.
|
|
233
|
+
// The plugin file's import resolution will fail in that case,
|
|
234
|
+
// which is fine — we conditionally exclude the plugin sources
|
|
235
|
+
// below.
|
|
236
|
+
if (findProject(':react-native-vision-camera') != null) {
|
|
237
|
+
compileOnly project(':react-native-vision-camera')
|
|
238
|
+
// CameraX `ImageProxy` / `ImageInfo` types — vision-camera
|
|
239
|
+
// exposes them through `Frame.getImageProxy()` and we need
|
|
240
|
+
// the compile-time class for `imageInfo.rotationDegrees`.
|
|
241
|
+
// `compileOnly` because the host app already ships these via
|
|
242
|
+
// vision-camera's transitive runtime dep.
|
|
243
|
+
compileOnly "androidx.camera:camera-core:1.5.0-alpha03"
|
|
244
|
+
} else {
|
|
245
|
+
// Without vision-camera on the classpath the Frame
|
|
246
|
+
// Processor plugin source can't compile (imports unresolved).
|
|
247
|
+
// Exclude it from the source set so the rest of the SDK
|
|
248
|
+
// still builds for non-camera consumers.
|
|
249
|
+
android.sourceSets.main.java.exclude '**/CvFlowGateFrameProcessor.kt'
|
|
250
|
+
}
|
|
218
251
|
}
|
|
219
252
|
|
|
220
253
|
// Helper from the React Native gradle convention to read host-app
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
package io.imagestitcher.rn
|
|
3
|
+
|
|
4
|
+
import android.media.Image
|
|
5
|
+
import androidx.annotation.Keep
|
|
6
|
+
import com.facebook.proguard.annotations.DoNotStrip
|
|
7
|
+
import com.mrousavy.camera.frameprocessors.Frame
|
|
8
|
+
import com.mrousavy.camera.frameprocessors.FrameProcessorPlugin
|
|
9
|
+
import com.mrousavy.camera.frameprocessors.VisionCameraProxy
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* F8.4 — Android vision-camera Frame Processor plugin that mirrors
|
|
13
|
+
* iOS' `KeyframeGateFrameProcessor.mm`.
|
|
14
|
+
*
|
|
15
|
+
* Plugin name (must match the iOS plugin):
|
|
16
|
+
* `cv_flow_gate_process_frame`
|
|
17
|
+
*
|
|
18
|
+
* JS-side usage is identical to iOS — the same `useFrameProcessorDriver`
|
|
19
|
+
* hook + the same `plugin.call(frame, args)` contract. The JS layer
|
|
20
|
+
* is 100% platform-agnostic.
|
|
21
|
+
*
|
|
22
|
+
* ## What this plugin does
|
|
23
|
+
*
|
|
24
|
+
* Per producer-thread frame:
|
|
25
|
+
* 1. Pull the `android.media.Image` out of vision-camera's `Frame`.
|
|
26
|
+
* 2. Extract pose primitives from the worklet's `params` dict
|
|
27
|
+
* (defaults safe for non-AR: tx/ty/tz=0, qw=1 identity, fx/fy=0
|
|
28
|
+
* → engine uses 65°×50° FoV fallback).
|
|
29
|
+
* 3. Call `IncrementalStitcher.consumeFrameFromPlugin(image, …)`
|
|
30
|
+
* which:
|
|
31
|
+
* - Drops the call if `frameSourceMode != "frameProcessor"`
|
|
32
|
+
* (prevents double-feeding the engine alongside the legacy
|
|
33
|
+
* `processFrameAtPath` path).
|
|
34
|
+
* - Otherwise: extracts the Y plane, evaluates the keyframe
|
|
35
|
+
* gate via `KeyframeGate.evaluateWithFrame`, encodes the
|
|
36
|
+
* accepted frame to JPEG synchronously, and hands the path
|
|
37
|
+
* to the existing `ingestFromARCameraView` engine entry.
|
|
38
|
+
*
|
|
39
|
+
* ## Lifetime / threading
|
|
40
|
+
*
|
|
41
|
+
* The `Frame` (and the underlying `Image` / `ImageProxy`) is valid
|
|
42
|
+
* only for the duration of this callback — vision-camera closes it
|
|
43
|
+
* on return. All Image access (including the JPEG encode on
|
|
44
|
+
* accept) MUST happen synchronously inside `callback()`.
|
|
45
|
+
*
|
|
46
|
+
* ## Divergence vs iOS
|
|
47
|
+
*
|
|
48
|
+
* iOS keeps the `CVPixelBuffer` reachable end-to-end into the
|
|
49
|
+
* stitcher engine (zero-copy). Android's engine entry point
|
|
50
|
+
* (`ingestFromARCameraView`) takes a Y `ByteArray` + a JPEG file
|
|
51
|
+
* path, so we copy Y bytes here and encode JPEG inline on accept.
|
|
52
|
+
* Cross-platform parity at the engine level is tracked as F8.6.
|
|
53
|
+
*
|
|
54
|
+
* ## Registration
|
|
55
|
+
*
|
|
56
|
+
* Registered in `RNImageStitcherPackage.kt`'s companion-object
|
|
57
|
+
* static initialiser via `FrameProcessorPluginRegistry`. Vision-
|
|
58
|
+
* camera docs say "should be called as soon as possible — ideally
|
|
59
|
+
* on app start or in a static initialiser"; the package class is
|
|
60
|
+
* loaded by RN autolinking at app startup, so the registration
|
|
61
|
+
* fires before any JS Frame Processor can `initFrameProcessorPlugin`
|
|
62
|
+
* the plugin.
|
|
63
|
+
*/
|
|
64
|
+
@DoNotStrip
|
|
65
|
+
@Keep
|
|
66
|
+
class CvFlowGateFrameProcessor(
|
|
67
|
+
proxy: VisionCameraProxy,
|
|
68
|
+
options: Map<String, Any>?,
|
|
69
|
+
) : FrameProcessorPlugin() {
|
|
70
|
+
|
|
71
|
+
// The `proxy` and `options` are accepted by the
|
|
72
|
+
// `PluginInitializer` contract but the plugin is stateless —
|
|
73
|
+
// all gate tunables live on `IncrementalStitcher` and are
|
|
74
|
+
// configured at its `start()` time from the host-app settings.
|
|
75
|
+
// The plugin is a thin pose-injector.
|
|
76
|
+
//
|
|
77
|
+
// Lint suppressors: we intentionally don't read these.
|
|
78
|
+
@Suppress("unused", "UNUSED_PARAMETER")
|
|
79
|
+
private val unused = proxy to options
|
|
80
|
+
|
|
81
|
+
@Suppress("UNCHECKED_CAST")
|
|
82
|
+
override fun callback(frame: Frame, params: Map<String, Any>?): Any? {
|
|
83
|
+
// Frame may throw `FrameInvalidError` if vision-camera has
|
|
84
|
+
// already released it. Defensive: swallow and return.
|
|
85
|
+
val image: Image = try {
|
|
86
|
+
frame.image
|
|
87
|
+
} catch (e: Throwable) {
|
|
88
|
+
return mapOf("submitted" to false, "error" to "frame invalid")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
val stitcher = IncrementalStitcher.bridgeInstance
|
|
92
|
+
if (stitcher == null) {
|
|
93
|
+
// Module never registered (host hasn't initialised the
|
|
94
|
+
// React bridge yet, or autolinking skipped us). Drop
|
|
95
|
+
// the call; JS sees `submitted: false` and can detect.
|
|
96
|
+
return mapOf("submitted" to false, "error" to "stitcher not registered")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// F8.4-Android-c rotation fix: read CameraX's authoritative
|
|
100
|
+
// "rotation needed to display upright" value via
|
|
101
|
+
// `imageProxy.imageInfo.rotationDegrees`.
|
|
102
|
+
//
|
|
103
|
+
// The earlier attempt used `Frame.orientation` (the enum),
|
|
104
|
+
// but vision-camera's `getOrientation()` returns the REVERSE
|
|
105
|
+
// of the rotation-needed value (see Frame.java:88, the
|
|
106
|
+
// "Reverse it" comment). Trying to invert the enum
|
|
107
|
+
// ourselves was off by 90° on the A35. The raw
|
|
108
|
+
// `imageInfo.rotationDegrees` is unambiguous.
|
|
109
|
+
//
|
|
110
|
+
// Used by the engine's JPEG encoder to write the correct
|
|
111
|
+
// EXIF Orientation tag so thumbnails (and any other
|
|
112
|
+
// EXIF-honoring viewer) display upright. The raw cv::Mat
|
|
113
|
+
// the stitcher sees is unaffected — see consumeFrameFromPlugin
|
|
114
|
+
// docstring for the no-double-rotation rationale.
|
|
115
|
+
val sensorRotationDegrees = try {
|
|
116
|
+
frame.imageProxy.imageInfo.rotationDegrees
|
|
117
|
+
} catch (_: Throwable) {
|
|
118
|
+
// FrameInvalidError or null mid-callback — treat as
|
|
119
|
+
// portrait back-camera default (sensor mounted 90° CW).
|
|
120
|
+
90
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
stitcher.consumeFrameFromPlugin(
|
|
124
|
+
image = image,
|
|
125
|
+
tx = argDouble(params, "tx", 0.0),
|
|
126
|
+
ty = argDouble(params, "ty", 0.0),
|
|
127
|
+
tz = argDouble(params, "tz", 0.0),
|
|
128
|
+
qx = argDouble(params, "qx", 0.0),
|
|
129
|
+
qy = argDouble(params, "qy", 0.0),
|
|
130
|
+
qz = argDouble(params, "qz", 0.0),
|
|
131
|
+
qw = argDouble(params, "qw", 1.0),
|
|
132
|
+
fx = argDouble(params, "fx", 0.0),
|
|
133
|
+
fy = argDouble(params, "fy", 0.0),
|
|
134
|
+
cx = argDouble(params, "cx", image.width / 2.0),
|
|
135
|
+
cy = argDouble(params, "cy", image.height / 2.0),
|
|
136
|
+
timestampMs = argDouble(params, "timestampMs", 0.0),
|
|
137
|
+
// Default 2 == `.tracking` so the worklet doesn't need
|
|
138
|
+
// to send a tracking-state field on every frame.
|
|
139
|
+
trackingStateRaw = argInt(params, "trackingStateRaw", 2),
|
|
140
|
+
sensorRotationDegrees = sensorRotationDegrees,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return mapOf("submitted" to true)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private fun argDouble(args: Map<String, Any>?, key: String, default: Double): Double {
|
|
147
|
+
if (args == null) return default
|
|
148
|
+
val v = args[key] ?: return default
|
|
149
|
+
return when (v) {
|
|
150
|
+
is Number -> v.toDouble()
|
|
151
|
+
else -> default
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private fun argInt(args: Map<String, Any>?, key: String, default: Int): Int {
|
|
156
|
+
if (args == null) return default
|
|
157
|
+
val v = args[key] ?: return default
|
|
158
|
+
return when (v) {
|
|
159
|
+
is Number -> v.toInt()
|
|
160
|
+
else -> default
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -36,6 +36,7 @@ import org.opencv.features2d.ORB
|
|
|
36
36
|
import org.opencv.imgcodecs.Imgcodecs
|
|
37
37
|
import org.opencv.imgproc.Imgproc
|
|
38
38
|
import java.io.File
|
|
39
|
+
import io.imagestitcher.rn.ar.YuvImageConverter
|
|
39
40
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
40
41
|
|
|
41
42
|
/**
|
|
@@ -76,6 +77,13 @@ class IncrementalStitcher(
|
|
|
76
77
|
private val reactContext: ReactApplicationContext,
|
|
77
78
|
) : ReactContextBaseJavaModule(reactContext) {
|
|
78
79
|
|
|
80
|
+
// F8.4 note: the static singleton accessor for cross-thread
|
|
81
|
+
// lookup (used by `CvFlowGateFrameProcessor` running on vision-
|
|
82
|
+
// camera's producer thread) is the existing `bridgeInstance`
|
|
83
|
+
// companion field below — same pattern that `RNSARCameraView`
|
|
84
|
+
// uses to call back into the bridge. No new companion object
|
|
85
|
+
// needed.
|
|
86
|
+
|
|
79
87
|
override fun getName(): String = "IncrementalStitcher"
|
|
80
88
|
|
|
81
89
|
/// Required by RCTEventEmitter contract. No-op on Android because
|
|
@@ -217,6 +225,21 @@ class IncrementalStitcher(
|
|
|
217
225
|
private var consumeFrameCounter: Long = 0L
|
|
218
226
|
|
|
219
227
|
private val isRunning = AtomicBoolean(false)
|
|
228
|
+
|
|
229
|
+
/// F8.4 — gate for `consumeFrameFromPlugin` (the vision-camera
|
|
230
|
+
/// Frame Processor producer-thread entry point on Android).
|
|
231
|
+
/// TRUE only when the current capture was started with
|
|
232
|
+
/// `frameSourceMode == "frameProcessor"`. In other modes
|
|
233
|
+
/// (especially the legacy `"jsDriver"` path that feeds via
|
|
234
|
+
/// `processFrameAtPath`), the plugin would double-feed the
|
|
235
|
+
/// engine — bytes from the producer thread + JPEG paths from
|
|
236
|
+
/// the JS interval, racing on the same workScope serial
|
|
237
|
+
/// dispatcher — so we drop the producer-thread call.
|
|
238
|
+
///
|
|
239
|
+
/// AtomicBoolean: producer thread reads lock-free, JS thread
|
|
240
|
+
/// (start/cancel/finalize) writes via `set()`/`compareAndSet()`.
|
|
241
|
+
/// Mirror of iOS' `frameProcessorIngestEnabled` ivar.
|
|
242
|
+
private val frameProcessorIngestEnabled = AtomicBoolean(false)
|
|
220
243
|
/// Critic #5 fix: serial dispatcher so concurrent
|
|
221
244
|
/// processFrameAtPath() calls can't race on the engine's canvas.
|
|
222
245
|
/// `limitedParallelism(1)` guarantees one-at-a-time execution
|
|
@@ -301,6 +324,15 @@ class IncrementalStitcher(
|
|
|
301
324
|
}
|
|
302
325
|
try {
|
|
303
326
|
ensureOpenCv()
|
|
327
|
+
// F8.4 — frameSourceMode honoured on Android. Pre-F8.4,
|
|
328
|
+
// Android ignored this option (only iOS interpreted it).
|
|
329
|
+
// Now `"frameProcessor"` unlocks `consumeFrameFromPlugin`'s
|
|
330
|
+
// producer-thread ingest path; everything else (the
|
|
331
|
+
// implicit default + the legacy `"jsDriver"`) keeps the
|
|
332
|
+
// ingest path dormant so the existing `processFrameAtPath`
|
|
333
|
+
// / ARCore paths run unmodified.
|
|
334
|
+
val frameSourceMode = options.getString("frameSourceMode") ?: "jsDriver"
|
|
335
|
+
frameProcessorIngestEnabled.set(frameSourceMode == "frameProcessor")
|
|
304
336
|
val rotation = options.getIntOrDefault("frameRotationDegrees", 90)
|
|
305
337
|
val composeW = options.getIntOrDefault("composeWidth", 960)
|
|
306
338
|
val composeH = options.getIntOrDefault("composeHeight", 720)
|
|
@@ -548,6 +580,7 @@ class IncrementalStitcher(
|
|
|
548
580
|
promise.resolve(map)
|
|
549
581
|
} catch (t: Throwable) {
|
|
550
582
|
isRunning.set(false)
|
|
583
|
+
frameProcessorIngestEnabled.set(false) // F8.4 — symmetric clear on error path
|
|
551
584
|
promise.reject("incremental-start-failed", t.message, t)
|
|
552
585
|
}
|
|
553
586
|
}
|
|
@@ -935,6 +968,7 @@ class IncrementalStitcher(
|
|
|
935
968
|
// bail at the re-check (see processFrameAtPath above).
|
|
936
969
|
// Matches iOS V12.1 fix.
|
|
937
970
|
isRunning.set(false)
|
|
971
|
+
frameProcessorIngestEnabled.set(false) // F8.4 — cut producer-thread ingest at finalize
|
|
938
972
|
|
|
939
973
|
// V16 batch-keyframe finalize: snapshot the keyframe state
|
|
940
974
|
// synchronously under the same "stop ingestion before
|
|
@@ -1133,6 +1167,7 @@ class IncrementalStitcher(
|
|
|
1133
1167
|
// iOS V12.1 cancel path.
|
|
1134
1168
|
arCameraViewRef?.setIncrementalIngestionActive(false)
|
|
1135
1169
|
isRunning.set(false)
|
|
1170
|
+
frameProcessorIngestEnabled.set(false) // F8.4 — cut producer-thread ingest at cancel
|
|
1136
1171
|
val hybrid = engine
|
|
1137
1172
|
val firstwins = firstwinsEngine
|
|
1138
1173
|
engine = null
|
|
@@ -1444,6 +1479,179 @@ class IncrementalStitcher(
|
|
|
1444
1479
|
}
|
|
1445
1480
|
}
|
|
1446
1481
|
|
|
1482
|
+
// ─── F8.4 — Frame Processor entry point ──────────────────────
|
|
1483
|
+
//
|
|
1484
|
+
// `consumeFrameFromPlugin` is the producer-thread ingress for
|
|
1485
|
+
// the vision-camera Frame Processor plugin
|
|
1486
|
+
// (`CvFlowGateFrameProcessor`). It takes a live
|
|
1487
|
+
// `android.media.Image` (held open by vision-camera for the
|
|
1488
|
+
// duration of the plugin callback) plus pose primitives, and
|
|
1489
|
+
// delegates to the existing `ingestFromARCameraView` after
|
|
1490
|
+
// extracting the Y plane bytes and wiring an inline JPEG
|
|
1491
|
+
// encoder for the on-accept lambda.
|
|
1492
|
+
//
|
|
1493
|
+
// ## Why this lives here (not on the plugin class)
|
|
1494
|
+
//
|
|
1495
|
+
// The plugin needs zero knowledge of the engine's internals
|
|
1496
|
+
// (batchKeyframeMode, eval-throttling, plane-latching, etc.)
|
|
1497
|
+
// — that's all in `ingestFromARCameraView`. Mirroring iOS'
|
|
1498
|
+
// `consumeFrameFromPlugin`, the wrapper just maps the public
|
|
1499
|
+
// primitive contract to the existing engine entry point.
|
|
1500
|
+
//
|
|
1501
|
+
// ## Why pass `Image` (not just the Y bytes)
|
|
1502
|
+
//
|
|
1503
|
+
// The engine's `ingestFromARCameraView` uses Y-only for the
|
|
1504
|
+
// keyframe gate. But when the gate ACCEPTS, the host (us) is
|
|
1505
|
+
// responsible for encoding the accepted frame as JPEG before
|
|
1506
|
+
// `ingestFromARCameraView` returns. YuvImage / NV21 needs the
|
|
1507
|
+
// full Y + interleaved VU planes, so we keep the Image
|
|
1508
|
+
// reachable through the lambda. Image's lifetime is bounded
|
|
1509
|
+
// by the plugin callback's return — vision-camera closes the
|
|
1510
|
+
// ImageProxy automatically — so the encode MUST be synchronous.
|
|
1511
|
+
//
|
|
1512
|
+
// ## Threading
|
|
1513
|
+
//
|
|
1514
|
+
// Called on vision-camera's frame-processor thread (a single-
|
|
1515
|
+
// thread executor). `frameProcessorIngestEnabled` is read
|
|
1516
|
+
// lock-free via AtomicBoolean. `ingestFromARCameraView`
|
|
1517
|
+
// dispatches the heavy engine work to `workScope` (serial),
|
|
1518
|
+
// so producer-thread blocking is bounded to the synchronous
|
|
1519
|
+
// gate evaluation + (on accept) JPEG encode — typically
|
|
1520
|
+
// 5–10 ms reject, 30–50 ms accept on a mid-tier device.
|
|
1521
|
+
fun consumeFrameFromPlugin(
|
|
1522
|
+
image: android.media.Image,
|
|
1523
|
+
tx: Double, ty: Double, tz: Double,
|
|
1524
|
+
qx: Double, qy: Double, qz: Double, qw: Double,
|
|
1525
|
+
fx: Double, fy: Double, cx: Double, cy: Double,
|
|
1526
|
+
timestampMs: Double,
|
|
1527
|
+
trackingStateRaw: Int,
|
|
1528
|
+
// F8.4-Android-c rotation fix: how many degrees the sensor
|
|
1529
|
+
// data needs to be rotated CW to display upright. Comes
|
|
1530
|
+
// from vision-camera's `Frame.imageProxy.imageInfo.rotationDegrees`.
|
|
1531
|
+
// Typically 90 for a portrait-held back camera on Samsung
|
|
1532
|
+
// devices (sensor mounted 90° rotated from screen-up).
|
|
1533
|
+
sensorRotationDegrees: Int,
|
|
1534
|
+
) {
|
|
1535
|
+
// F8.4 — drop the call unless this capture was started in
|
|
1536
|
+
// frameProcessor mode. Otherwise the plugin would double-
|
|
1537
|
+
// feed the engine alongside the legacy jsDriver /
|
|
1538
|
+
// processFrameAtPath path. See the flag's declaration
|
|
1539
|
+
// for the full reasoning. Mirrors iOS H1.
|
|
1540
|
+
if (!frameProcessorIngestEnabled.get()) return
|
|
1541
|
+
|
|
1542
|
+
val width = image.width
|
|
1543
|
+
val height = image.height
|
|
1544
|
+
val yPlane = image.planes[0]
|
|
1545
|
+
val yRowStride = yPlane.rowStride
|
|
1546
|
+
|
|
1547
|
+
// Read Y plane bytes. ByteBuffer.get() advances position;
|
|
1548
|
+
// copy into our own ByteArray so the engine's downstream
|
|
1549
|
+
// workScope can safely outlive this method (the Image
|
|
1550
|
+
// closes after callback returns, but we've already copied).
|
|
1551
|
+
val yBuffer = yPlane.buffer
|
|
1552
|
+
val yBytes = ByteArray(yBuffer.remaining())
|
|
1553
|
+
yBuffer.get(yBytes)
|
|
1554
|
+
|
|
1555
|
+
// Compute derived params expected by the existing ingest
|
|
1556
|
+
// API. Quaternion-to-yaw/pitch follows the same convention
|
|
1557
|
+
// useFrameProcessorDriver synthesises on JS (q_yaw * q_pitch).
|
|
1558
|
+
//
|
|
1559
|
+
// yaw = atan2(2(qw*qy + qx*qz), 1 - 2(qy² + qz²))
|
|
1560
|
+
// pitch = asin(clamp(2(qw*qx - qz*qy), -1, 1))
|
|
1561
|
+
val yaw = kotlin.math.atan2(
|
|
1562
|
+
2.0 * (qw * qy + qx * qz),
|
|
1563
|
+
1.0 - 2.0 * (qy * qy + qz * qz),
|
|
1564
|
+
)
|
|
1565
|
+
val pitch = kotlin.math.asin(
|
|
1566
|
+
(2.0 * (qw * qx - qz * qy)).coerceIn(-1.0, 1.0),
|
|
1567
|
+
)
|
|
1568
|
+
|
|
1569
|
+
// FoV from intrinsics + dims. fx == 0 is the "JS didn't
|
|
1570
|
+
// supply" signal (the iOS wrapper has the same default);
|
|
1571
|
+
// fall back to a 65°×50° estimate so the engine doesn't
|
|
1572
|
+
// see NaN.
|
|
1573
|
+
val fovHorizDegrees = if (fx > 0.0)
|
|
1574
|
+
2.0 * kotlin.math.atan(width.toDouble() / (2.0 * fx)) * 180.0 / Math.PI
|
|
1575
|
+
else 65.0
|
|
1576
|
+
val fovVertDegrees = if (fy > 0.0)
|
|
1577
|
+
2.0 * kotlin.math.atan(height.toDouble() / (2.0 * fy)) * 180.0 / Math.PI
|
|
1578
|
+
else 50.0
|
|
1579
|
+
|
|
1580
|
+
// `2` == `.tracking` per the iOS RNSARTrackingState enum.
|
|
1581
|
+
// Anything else maps to trackingPoor=true, routing the
|
|
1582
|
+
// frame through the engine's degraded-tracking branches
|
|
1583
|
+
// (failing closed; symmetric with iOS C2).
|
|
1584
|
+
val trackingPoor = trackingStateRaw != 2
|
|
1585
|
+
|
|
1586
|
+
ingestFromARCameraView(
|
|
1587
|
+
tx = tx, ty = ty, tz = tz,
|
|
1588
|
+
qx = qx, qy = qy, qz = qz, qw = qw,
|
|
1589
|
+
fx = fx, fy = fy, cx = cx, cy = cy,
|
|
1590
|
+
imageWidth = width, imageHeight = height,
|
|
1591
|
+
yaw = yaw, pitch = pitch,
|
|
1592
|
+
fovHorizDegrees = fovHorizDegrees,
|
|
1593
|
+
fovVertDegrees = fovVertDegrees,
|
|
1594
|
+
trackingPoor = trackingPoor,
|
|
1595
|
+
grayData = yBytes,
|
|
1596
|
+
grayWidth = width,
|
|
1597
|
+
grayHeight = height,
|
|
1598
|
+
grayStride = yRowStride,
|
|
1599
|
+
onAccept = { targetPath ->
|
|
1600
|
+
// Synchronous JPEG encode via the existing
|
|
1601
|
+
// YuvImageConverter (also used by RNSARCameraView's
|
|
1602
|
+
// ARCore path). Handles both the NV21 conversion
|
|
1603
|
+
// (stride / pixelStride aware) and the EXIF
|
|
1604
|
+
// Orientation tag write so the JPEG displays upright
|
|
1605
|
+
// in the UI thumbnail strip + any RN Image consumer.
|
|
1606
|
+
//
|
|
1607
|
+
// EXIF rotation is BAKED-AS-METADATA, not pixel-
|
|
1608
|
+
// rotated. cv::imread in the stitcher ignores EXIF
|
|
1609
|
+
// by default (see BatchStitcher.applyExifOrientation),
|
|
1610
|
+
// so the engine's stored `frameRotationDegrees` still
|
|
1611
|
+
// governs how the cv::Mat is interpreted downstream.
|
|
1612
|
+
// No double-rotation.
|
|
1613
|
+
//
|
|
1614
|
+
// Returning `true` tells the engine the keyframe was
|
|
1615
|
+
// persisted; `false` tells it to drop the accept.
|
|
1616
|
+
try {
|
|
1617
|
+
val packed = YuvImageConverter.packNV21(image)
|
|
1618
|
+
?: run {
|
|
1619
|
+
android.util.Log.w(
|
|
1620
|
+
"IncrementalStitcher",
|
|
1621
|
+
"consumeFrameFromPlugin: packNV21 returned null for $targetPath",
|
|
1622
|
+
)
|
|
1623
|
+
return@run null
|
|
1624
|
+
}
|
|
1625
|
+
if (packed == null) {
|
|
1626
|
+
false
|
|
1627
|
+
} else {
|
|
1628
|
+
val displayRotation = when (sensorRotationDegrees) {
|
|
1629
|
+
0 -> android.view.Surface.ROTATION_90
|
|
1630
|
+
90 -> android.view.Surface.ROTATION_0
|
|
1631
|
+
180 -> android.view.Surface.ROTATION_270
|
|
1632
|
+
270 -> android.view.Surface.ROTATION_180
|
|
1633
|
+
else -> android.view.Surface.ROTATION_0
|
|
1634
|
+
}
|
|
1635
|
+
val outPath = YuvImageConverter.encodeJpegFromNV21(
|
|
1636
|
+
packed,
|
|
1637
|
+
targetPath,
|
|
1638
|
+
jpegQuality = 80,
|
|
1639
|
+
displayRotation = displayRotation,
|
|
1640
|
+
)
|
|
1641
|
+
outPath != null
|
|
1642
|
+
}
|
|
1643
|
+
} catch (e: Throwable) {
|
|
1644
|
+
android.util.Log.w(
|
|
1645
|
+
"IncrementalStitcher",
|
|
1646
|
+
"consumeFrameFromPlugin: JPEG encode failed for $targetPath: ${e.javaClass.simpleName}: ${e.message}",
|
|
1647
|
+
e,
|
|
1648
|
+
)
|
|
1649
|
+
false
|
|
1650
|
+
}
|
|
1651
|
+
},
|
|
1652
|
+
)
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1447
1655
|
@ReactMethod
|
|
1448
1656
|
fun getState(promise: Promise) {
|
|
1449
1657
|
val state = firstwinsEngine?.lastState ?: engine?.lastState
|
|
@@ -1944,6 +2152,12 @@ class IncrementalStitcher(
|
|
|
1944
2152
|
// Ignore — not critical at teardown.
|
|
1945
2153
|
}
|
|
1946
2154
|
captureSessionDir = null
|
|
2155
|
+
// F8.4 — release the static back-pointer so the Frame
|
|
2156
|
+
// Processor plugin sees a clean nil after bridge teardown.
|
|
2157
|
+
// A new bridge will set it again via the init block.
|
|
2158
|
+
if (bridgeInstance === this) {
|
|
2159
|
+
bridgeInstance = null
|
|
2160
|
+
}
|
|
1947
2161
|
super.onCatalystInstanceDestroy()
|
|
1948
2162
|
}
|
|
1949
2163
|
|
|
@@ -5,6 +5,7 @@ import com.facebook.react.ReactPackage
|
|
|
5
5
|
import com.facebook.react.bridge.NativeModule
|
|
6
6
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
7
7
|
import com.facebook.react.uimanager.ViewManager
|
|
8
|
+
import com.mrousavy.camera.frameprocessors.FrameProcessorPluginRegistry
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* ReactPackage that registers the SDK's two native modules with
|
|
@@ -22,15 +23,72 @@ import com.facebook.react.uimanager.ViewManager
|
|
|
22
23
|
* JS layer.
|
|
23
24
|
*/
|
|
24
25
|
class RNImageStitcherPackage : ReactPackage {
|
|
26
|
+
|
|
27
|
+
companion object {
|
|
28
|
+
@Volatile
|
|
29
|
+
private var fpPluginRegistered = false
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* F8.4 — register the vision-camera Frame Processor plugin.
|
|
33
|
+
* Called lazily from `createNativeModules` (which fires
|
|
34
|
+
* AFTER the React bridge has booted, side-stepping the
|
|
35
|
+
* bridgeless TurboModule init race we'd hit if we did this
|
|
36
|
+
* in a class-level static initialiser).
|
|
37
|
+
*
|
|
38
|
+
* No-op when vision-camera isn't on the runtime classpath
|
|
39
|
+
* (the SDK doesn't hard-depend on it — consumers that don't
|
|
40
|
+
* use `<Camera>` don't pay the dep). Catches
|
|
41
|
+
* `NoClassDefFoundError` defensively because the runtime
|
|
42
|
+
* classpath is what matters, not the compile-time one.
|
|
43
|
+
*
|
|
44
|
+
* Idempotent: guarded by `fpPluginRegistered` so a host
|
|
45
|
+
* with multiple React instances doesn't double-register
|
|
46
|
+
* (would throw "name already exists" from the registry).
|
|
47
|
+
*/
|
|
48
|
+
@JvmStatic
|
|
49
|
+
@Synchronized
|
|
50
|
+
fun ensureFrameProcessorPluginRegistered() {
|
|
51
|
+
if (fpPluginRegistered) return
|
|
52
|
+
try {
|
|
53
|
+
FrameProcessorPluginRegistry.addFrameProcessorPlugin(
|
|
54
|
+
"cv_flow_gate_process_frame",
|
|
55
|
+
) { proxy, options ->
|
|
56
|
+
CvFlowGateFrameProcessor(proxy, options)
|
|
57
|
+
}
|
|
58
|
+
fpPluginRegistered = true
|
|
59
|
+
} catch (e: NoClassDefFoundError) {
|
|
60
|
+
android.util.Log.i(
|
|
61
|
+
"RNImageStitcherPackage",
|
|
62
|
+
"vision-camera FrameProcessorPluginRegistry not on classpath — "
|
|
63
|
+
+ "skipping cv_flow_gate_process_frame plugin registration "
|
|
64
|
+
+ "(host app doesn't appear to use Frame Processors).",
|
|
65
|
+
)
|
|
66
|
+
fpPluginRegistered = true // don't retry every package init
|
|
67
|
+
} catch (e: Throwable) {
|
|
68
|
+
android.util.Log.w(
|
|
69
|
+
"RNImageStitcherPackage",
|
|
70
|
+
"Failed to register cv_flow_gate_process_frame plugin: ${e.message}",
|
|
71
|
+
)
|
|
72
|
+
fpPluginRegistered = true
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
25
77
|
override fun createNativeModules(
|
|
26
78
|
reactContext: ReactApplicationContext,
|
|
27
|
-
): List<NativeModule>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
79
|
+
): List<NativeModule> {
|
|
80
|
+
// F8.4 — register the Frame Processor plugin here, after the
|
|
81
|
+
// bridge is fully booted. See `ensureFrameProcessorPluginRegistered`
|
|
82
|
+
// for the rationale (vs. a class-load-time static init).
|
|
83
|
+
ensureFrameProcessorPluginRegistered()
|
|
84
|
+
return listOf(
|
|
85
|
+
QualityChecker(reactContext),
|
|
86
|
+
BatchStitcher(reactContext),
|
|
87
|
+
RNSARSession(reactContext),
|
|
88
|
+
IncrementalStitcher(reactContext),
|
|
89
|
+
FileBridge(reactContext),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
34
92
|
|
|
35
93
|
override fun createViewManagers(
|
|
36
94
|
reactContext: ReactApplicationContext,
|