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 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
@@ -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> = listOf(
28
- QualityChecker(reactContext),
29
- BatchStitcher(reactContext),
30
- RNSARSession(reactContext),
31
- IncrementalStitcher(reactContext),
32
- FileBridge(reactContext),
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,