react-native-image-stitcher 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -16,6 +16,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.5.1] — 2026-05-25
20
+
21
+ ### Added — F8.6 Android pixel-buffer engine parity
22
+
23
+ Closes the v0.5.0 follow-up tracked in the [0.5.0] section.
24
+
25
+ **Live engine ingest no longer requires a JPEG round-trip.**
26
+ The `IncrementalFirstwinsEngine` (slit-scan / first-wins) and the
27
+ hybrid `IncrementalEngine` both gained a new
28
+ `addFramePixelData(nv21, w, h, ...)` method. It builds the BGR
29
+ `cv::Mat` in-process via
30
+ `Imgproc.cvtColor(yuv, COLOR_YUV2BGR_NV21)`, then delegates to a
31
+ newly-extracted shared `addFrameMat` helper that runs the original
32
+ engine pipeline verbatim. The legacy `addFrameAtPath(path, ...)`
33
+ is now a thin wrapper: `imread → downsample → addFrameMat`.
34
+
35
+ **Routing.** `IncrementalStitcher.ingestFromARCameraView` got
36
+ three optional parameters — `nv21PixelData: ByteArray?`,
37
+ `nv21PixelWidth: Int`, `nv21PixelHeight: Int`. When supplied (and
38
+ `batchKeyframeMode == false`), the live engine ingests via
39
+ `addFramePixelData`; otherwise falls back to `addFrameAtPath` with
40
+ `legacyJpegPath`. Backwards-compatible — all-null defaults
41
+ preserve every existing caller.
42
+
43
+ **Frame Processor wiring.** `consumeFrameFromPlugin` now packs the
44
+ incoming `Image` NV21 once at the top (was twice — gate consumed
45
+ Y only, then the `onAccept` lambda re-packed for JPEG encode) and
46
+ threads the bytes through to both the gate (which reads only the
47
+ Y subset) AND the new `nv21PixelData` parameter. Net: single
48
+ `packNV21` per producer-thread frame.
49
+
50
+ **Measured on Galaxy A35, `engine: 'firstwins-rectilinear'`,
51
+ non-AR Frame Processor capture:**
52
+
53
+ | Outcome | F8.6 pixel-data | Legacy JPEG path (estimated) |
54
+ |---|---|---|
55
+ | `AcceptedHigh` (first-frame init) | 7–11 ms | 50–70 ms |
56
+ | `SkippedTooClose` (gate bail) | 0.5–2 ms | 50–60 ms (imread is unconditional) |
57
+
58
+ `SkippedTooClose` dominates the producer-thread frame budget
59
+ (~95% of frames at 30 fps with a slow pan). Eliminating the
60
+ imread on those frames is the bulk of the F8.6 win.
61
+
62
+ ### Added
63
+
64
+ * New `<Camera engine={...}>` prop exposes the live engine
65
+ selection (`'batch-keyframe'` (default) / `'firstwins-rectilinear'`
66
+ / `'hybrid'` / `'slitscan-*'`). Lets hosts opt into in-flight
67
+ stitching for low-latency previews; previously the choice was
68
+ hardcoded.
69
+
70
+ ### Changed
71
+
72
+ * `New: F8.6 perf-diagnostic logs` (`F8.6-route`, `F8.6-perf`) fire
73
+ in live-engine mode only — inert under the default
74
+ `batch-keyframe`. Will be removed in v0.6 once F8.6 is baked in
75
+ production.
76
+
77
+ ### Fixed
78
+
79
+ * In `IncrementalStitcher.consumeFrameFromPlugin`, the `onAccept`
80
+ lambda was re-packing the live `Image` instead of reusing the
81
+ already-packed NV21 from the outer scope. Now it reuses the
82
+ outer `packed` — saves a redundant `packNV21` call on every
83
+ accepted frame.
84
+
19
85
  ## [0.5.0] — 2026-05-25
20
86
 
21
87
  ### Added — F8 Frame Processor port
@@ -91,13 +157,29 @@ Frame Processor** on the camera producer thread instead of the
91
157
 
92
158
  ### Tracking — known follow-ups (don't gate this release)
93
159
 
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.
160
+ - **F8.6 (v0.5.1)** — Android engine refactor for pixel-buffer-
161
+ direct ingest (true zero-copy parity with iOS). Would extract
162
+ an `addFrameMat` helper from `IncrementalFirstwinsEngine` and
163
+ `IncrementalEngine`'s `addFrameAtPath`, add a parallel
164
+ `addFramePixelData` that constructs the BGR `cv::Mat` from NV21
165
+ bytes via `cvtColor`, and rewire `RNSARCameraView` to skip the
166
+ per-frame JPEG encode. Expected gain: ~30–50 ms per accepted
167
+ frame. Deferred because the engine bodies are 400+ lines of
168
+ complex AR-mode code; needs A35 device verification before
169
+ merge, which the v0.5.0 prep session didn't have.
170
+
171
+ - **F8.3-followup-roll** — resolved in v0.5.0.
172
+
173
+ - **F8.3.H2-target** — RESOLVED in v0.5.0 via a different
174
+ mechanism than originally planned. The selector pin is now a
175
+ compile-time `#selector(...)` reference inside
176
+ `IncrementalStitcher.swift` plus a dev-build runtime assert in
177
+ `IncrementalStitcher.init()` — both fire if the Swift method
178
+ signature drifts from what `KeyframeGateFrameProcessor.mm`
179
+ expects. The obsolete test file was deleted. `swift test` now
180
+ runs the (8-test) `QualityCheckerTests` suite cleanly because
181
+ `Package.swift` switched from an exclude list (broke every time
182
+ a new `.mm` landed) to an explicit `sources` allowlist.
101
183
 
102
184
  ## [0.4.1] — 2026-05-23
103
185
 
@@ -197,7 +197,155 @@ internal class IncrementalFirstwinsEngine(
197
197
  }
198
198
  val frameBGR = downsampleToCompose(srcRaw)
199
199
  if (frameBGR !== srcRaw) srcRaw.release()
200
+ val tele = addFrameMat(
201
+ frameBGR,
202
+ qx, qy, qz, qw,
203
+ fx, fy, cx, cy,
204
+ imageWidth, imageHeight,
205
+ yaw, pitch,
206
+ fovHorizDegrees, fovVertDegrees,
207
+ t0,
208
+ )
209
+ f8_6_logPerf("firstwins/jpeg", t0, tele.outcome)
210
+ return tele
211
+ }
212
+
213
+ /**
214
+ * F8.6 — pixel-data twin of [addFrameAtPath]. Accepts the
215
+ * camera frame as an NV21 byte buffer instead of a JPEG file
216
+ * path; skips the JPEG decode round-trip (~30–50 ms per
217
+ * accepted frame on a mid-tier device).
218
+ *
219
+ * Use this from the vision-camera Frame Processor path (where
220
+ * we already have direct producer-thread access to YUV bytes)
221
+ * and from the ARCore path (where the previous
222
+ * JPEG-encode-on-every-frame in `RNSARCameraView` was a
223
+ * measurable hot-spot).
224
+ *
225
+ * The body matches [addFrameAtPath] one-for-one except for the
226
+ * Mat construction: an `Mat(h*3/2, w, CV_8UC1)` wraps the
227
+ * NV21 bytes, then `Imgproc.cvtColor` produces the BGR Mat the
228
+ * engine pipeline already expects. Everything downstream is
229
+ * the shared [addFrameMat] helper.
230
+ *
231
+ * `nv21Width`/`nv21Height` describe the buffer's actual
232
+ * dimensions. `imageWidth`/`imageHeight` describe the
233
+ * camera's reported sensor dims used for intrinsics scaling —
234
+ * these can differ when the camera is downsampling for Frame
235
+ * Processor output.
236
+ */
237
+ fun addFramePixelData(
238
+ nv21: ByteArray,
239
+ nv21Width: Int,
240
+ nv21Height: Int,
241
+ qx: Double, qy: Double, qz: Double, qw: Double,
242
+ fx: Double, fy: Double, cx: Double, cy: Double,
243
+ imageWidth: Int, imageHeight: Int,
244
+ yaw: Double, pitch: Double,
245
+ fovHorizDegrees: Double, fovVertDegrees: Double,
246
+ trackingPoor: Boolean,
247
+ ): FrameTelemetry {
248
+ val t0 = System.nanoTime()
249
+ if (trackingPoor) {
250
+ return FrameTelemetry(
251
+ FrameOutcome.SkippedTrackingPoor, -1.0, 0, yaw, pitch,
252
+ msSince(t0),
253
+ isLandscape = isLandscape,
254
+ )
255
+ }
256
+ // NV21 layout: Y plane (w*h bytes) + interleaved VU
257
+ // (w*h/2 bytes). A single CV_8UC1 Mat of height h*3/2
258
+ // packs the whole thing; cvtColor with COLOR_YUV2BGR_NV21
259
+ // does the planar-aware decode in one call.
260
+ //
261
+ // F8.6 IS-1 — length guard. If the caller supplied a
262
+ // short buffer, `yuv.put(0,0,nv21)` would copy only
263
+ // `nv21.size` bytes and leave the rest zero-init; cvtColor
264
+ // would then read stale/zero UV and produce silently
265
+ // corrupt colour. Fail fast instead.
266
+ val expectedBytes = nv21Width * nv21Height * 3 / 2
267
+ require(nv21.size >= expectedBytes) {
268
+ "addFramePixelData: nv21 buffer too small " +
269
+ "(${nv21.size} bytes < $expectedBytes for " +
270
+ "${nv21Width}x${nv21Height})"
271
+ }
272
+ val yuv = Mat(nv21Height + nv21Height / 2, nv21Width, CvType.CV_8UC1)
273
+ yuv.put(0, 0, nv21)
274
+ val srcRaw = Mat()
275
+ Imgproc.cvtColor(yuv, srcRaw, Imgproc.COLOR_YUV2BGR_NV21)
276
+ yuv.release()
277
+ if (srcRaw.empty()) {
278
+ return FrameTelemetry(
279
+ FrameOutcome.RejectedAlignmentLost, -1.0, 0, yaw, pitch,
280
+ msSince(t0),
281
+ isLandscape = isLandscape,
282
+ )
283
+ }
284
+ val frameBGR = downsampleToCompose(srcRaw)
285
+ if (frameBGR !== srcRaw) srcRaw.release()
286
+ val tele = addFrameMat(
287
+ frameBGR,
288
+ qx, qy, qz, qw,
289
+ fx, fy, cx, cy,
290
+ imageWidth, imageHeight,
291
+ yaw, pitch,
292
+ fovHorizDegrees, fovVertDegrees,
293
+ t0,
294
+ )
295
+ f8_6_logPerf("firstwins/pixel", t0, tele.outcome)
296
+ return tele
297
+ }
200
298
 
299
+ /**
300
+ * F8.6 perf-diagnostic counter. Logs ingest timing every Nth
301
+ * call so a single capture session yields enough samples to
302
+ * eyeball the JPEG-path vs pixel-data-path delta. Remove this
303
+ * once F8.6 is verified in production.
304
+ */
305
+ @Volatile private var f8_6_perfCallCounter: Long = 0L
306
+ private fun f8_6_logPerf(
307
+ path: String,
308
+ t0Nanos: Long,
309
+ outcome: FrameOutcome,
310
+ ) {
311
+ val n = ++f8_6_perfCallCounter
312
+ // Every 5th call ≈ ~1 line/sec at 5 Hz live-engine rate;
313
+ // first call always logs so we see something on capture
314
+ // start without waiting.
315
+ if (n == 1L || n % 5L == 0L) {
316
+ Log.i(
317
+ "F8.6-perf",
318
+ "$path took ${msSince(t0Nanos)}ms outcome=$outcome (call #$n)",
319
+ )
320
+ }
321
+ }
322
+
323
+ /**
324
+ * F8.6 — the body extracted from [addFrameAtPath]. Takes a
325
+ * BGR `Mat` (downsampled to compose dims) and runs the full
326
+ * engine pipeline: first-frame init or subsequent-frame paste
327
+ * via either rectilinear or cylindrical warp.
328
+ *
329
+ * Behaviour is identical to the pre-F8.6 `addFrameAtPath`
330
+ * (the body is a verbatim move). Both `addFrameAtPath` and
331
+ * `addFramePixelData` delegate here after their respective
332
+ * Mat constructions.
333
+ */
334
+ private fun addFrameMat(
335
+ frameBGR: Mat,
336
+ qx: Double, qy: Double, qz: Double, qw: Double,
337
+ fx: Double, fy: Double, cx: Double, cy: Double,
338
+ imageWidth: Int, imageHeight: Int,
339
+ yaw: Double, pitch: Double,
340
+ // FOV params kept for symmetry with `IncrementalEngine.addFrameMat`
341
+ // (the hybrid engine, which uses them in `computeOverlapPct`).
342
+ // The firstwins/slit-scan engine here doesn't consume them —
343
+ // its paste decision is driven by pose-projected pixel
344
+ // displacement, not FoV-overlap percent.
345
+ @Suppress("UNUSED_PARAMETER") fovHorizDegrees: Double,
346
+ @Suppress("UNUSED_PARAMETER") fovVertDegrees: Double,
347
+ t0: Long,
348
+ ): FrameTelemetry {
201
349
  val rNew = quaternionToRotationMat(qx, qy, qz, qw)
202
350
 
203
351
  if (!hasFirstFrame) {
@@ -240,6 +240,11 @@ class IncrementalStitcher(
240
240
  /// (start/cancel/finalize) writes via `set()`/`compareAndSet()`.
241
241
  /// Mirror of iOS' `frameProcessorIngestEnabled` ivar.
242
242
  private val frameProcessorIngestEnabled = AtomicBoolean(false)
243
+
244
+ /// F8.6 perf-diagnostic — one-shot guard so the "live-engine
245
+ /// route" log fires exactly once per capture session. Reset
246
+ /// in start().
247
+ private val f8_6_routeLoggedThisCapture = AtomicBoolean(false)
243
248
  /// Critic #5 fix: serial dispatcher so concurrent
244
249
  /// processFrameAtPath() calls can't race on the engine's canvas.
245
250
  /// `limitedParallelism(1)` guarantees one-at-a-time execution
@@ -333,6 +338,9 @@ class IncrementalStitcher(
333
338
  // / ARCore paths run unmodified.
334
339
  val frameSourceMode = options.getString("frameSourceMode") ?: "jsDriver"
335
340
  frameProcessorIngestEnabled.set(frameSourceMode == "frameProcessor")
341
+ // F8.6 perf-diagnostic — re-arm the route log for the
342
+ // new capture.
343
+ f8_6_routeLoggedThisCapture.set(false)
336
344
  val rotation = options.getIntOrDefault("frameRotationDegrees", 90)
337
345
  val composeW = options.getIntOrDefault("composeWidth", 960)
338
346
  val composeH = options.getIntOrDefault("composeHeight", 720)
@@ -1262,6 +1270,24 @@ class IncrementalStitcher(
1262
1270
  // can check `isBatchKeyframeMode` to elide the per-frame
1263
1271
  // JPEG encode for the batch path.
1264
1272
  legacyJpegPath: String? = null,
1273
+ // F8.6 — pixel-data path for live engines. When supplied
1274
+ // (and `batchKeyframeMode == false`), takes precedence over
1275
+ // `legacyJpegPath`: the live engine ingests via
1276
+ // `addFramePixelData` (NV21 → BGR Mat in-process) instead of
1277
+ // `addFrameAtPath` (JPEG decode round-trip). Saves ~30-50 ms
1278
+ // per accepted frame on a mid-tier device. Pass null to use
1279
+ // the legacy JPEG path.
1280
+ //
1281
+ // OWNERSHIP: the engine retains a reference to `nv21PixelData`
1282
+ // until `workScope`'s coroutine consumes it (~50 ms later).
1283
+ // Callers MUST treat the array as transferred — do not
1284
+ // mutate it or return it to a buffer pool after calling
1285
+ // this method. If a caller needs to recycle the buffer,
1286
+ // pass `.copyOf()` (currently no caller does — the F8.4
1287
+ // Frame Processor plugin allocates a fresh array per frame).
1288
+ nv21PixelData: ByteArray? = null,
1289
+ nv21PixelWidth: Int = 0,
1290
+ nv21PixelHeight: Int = 0,
1265
1291
  ) {
1266
1292
  // ── V16 batch-keyframe: AR-driven path ─────────────────────
1267
1293
  //
@@ -1439,40 +1465,91 @@ class IncrementalStitcher(
1439
1465
  // we only get here when batchKeyframeMode == false. Caller
1440
1466
  // (RNSARCameraView) was expected to supply legacyJpegPath in
1441
1467
  // that case — defensively drop the frame if it didn't.
1442
- val path = legacyJpegPath ?: run {
1468
+ // F8.6 — prefer the pixel-data path when the caller supplied
1469
+ // NV21 bytes (Frame Processor / refactored ARCore path),
1470
+ // otherwise fall back to legacyJpegPath (un-migrated ARCore
1471
+ // path). At least one of them must be present; drop the
1472
+ // frame defensively otherwise.
1473
+ val hasPixelData = nv21PixelData != null
1474
+ && nv21PixelWidth > 0
1475
+ && nv21PixelHeight > 0
1476
+ // F8.6 perf-diagnostic — log route choice once per capture
1477
+ // so logcat shows whether the new pixel-data path is
1478
+ // actually getting exercised. Throttled to first-hit per
1479
+ // capture (counter resets in start()/cancel()).
1480
+ if (!f8_6_routeLoggedThisCapture.getAndSet(true)) {
1481
+ android.util.Log.i(
1482
+ "F8.6-route",
1483
+ "ingestFromARCameraView live-engine route: "
1484
+ + if (hasPixelData) "pixel-data (F8.6, no JPEG round-trip)"
1485
+ else "jpeg-path (legacy, JPEG decode round-trip)",
1486
+ )
1487
+ }
1488
+ val path = if (hasPixelData) null else legacyJpegPath ?: run {
1443
1489
  android.util.Log.w(
1444
1490
  "IncrementalStitcher",
1445
1491
  "ingestFromARCameraView legacy: batchKeyframeMode=false " +
1446
- "but legacyJpegPath is null — dropping frame. " +
1447
- "Caller should have encoded a JPEG when " +
1492
+ "but both legacyJpegPath and nv21PixelData are null — " +
1493
+ "dropping frame. Caller should have encoded a JPEG " +
1494
+ "OR supplied NV21 pixel data when " +
1448
1495
  "isBatchKeyframeMode == false.",
1449
1496
  )
1450
1497
  return
1451
1498
  }
1452
1499
  workScope.launch {
1453
1500
  val state: WritableMap? = if (firstwins != null) {
1454
- val tele = firstwins.addFrameAtPath(
1455
- path = path,
1456
- qx = qx, qy = qy, qz = qz, qw = qw,
1457
- fx = fx, fy = fy, cx = cx, cy = cy,
1458
- imageWidth = imageWidth, imageHeight = imageHeight,
1459
- yaw = yaw, pitch = pitch,
1460
- fovHorizDegrees = fovHorizDegrees,
1461
- fovVertDegrees = fovVertDegrees,
1462
- trackingPoor = trackingPoor,
1463
- )
1501
+ val tele = if (hasPixelData) {
1502
+ firstwins.addFramePixelData(
1503
+ nv21 = nv21PixelData!!,
1504
+ nv21Width = nv21PixelWidth,
1505
+ nv21Height = nv21PixelHeight,
1506
+ qx = qx, qy = qy, qz = qz, qw = qw,
1507
+ fx = fx, fy = fy, cx = cx, cy = cy,
1508
+ imageWidth = imageWidth, imageHeight = imageHeight,
1509
+ yaw = yaw, pitch = pitch,
1510
+ fovHorizDegrees = fovHorizDegrees,
1511
+ fovVertDegrees = fovVertDegrees,
1512
+ trackingPoor = trackingPoor,
1513
+ )
1514
+ } else {
1515
+ firstwins.addFrameAtPath(
1516
+ path = path!!,
1517
+ qx = qx, qy = qy, qz = qz, qw = qw,
1518
+ fx = fx, fy = fy, cx = cx, cy = cy,
1519
+ imageWidth = imageWidth, imageHeight = imageHeight,
1520
+ yaw = yaw, pitch = pitch,
1521
+ fovHorizDegrees = fovHorizDegrees,
1522
+ fovVertDegrees = fovVertDegrees,
1523
+ trackingPoor = trackingPoor,
1524
+ )
1525
+ }
1464
1526
  firstwins.snapshotIfDue(tele)
1465
1527
  } else {
1466
- val tele = hybrid!!.addFrameAtPath(
1467
- path = path,
1468
- qx = qx, qy = qy, qz = qz, qw = qw,
1469
- fx = fx, fy = fy, cx = cx, cy = cy,
1470
- imageWidth = imageWidth, imageHeight = imageHeight,
1471
- yaw = yaw, pitch = pitch,
1472
- fovHorizDegrees = fovHorizDegrees,
1473
- fovVertDegrees = fovVertDegrees,
1474
- trackingPoor = trackingPoor,
1475
- )
1528
+ val tele = if (hasPixelData) {
1529
+ hybrid!!.addFramePixelData(
1530
+ nv21 = nv21PixelData!!,
1531
+ nv21Width = nv21PixelWidth,
1532
+ nv21Height = nv21PixelHeight,
1533
+ qx = qx, qy = qy, qz = qz, qw = qw,
1534
+ fx = fx, fy = fy, cx = cx, cy = cy,
1535
+ imageWidth = imageWidth, imageHeight = imageHeight,
1536
+ yaw = yaw, pitch = pitch,
1537
+ fovHorizDegrees = fovHorizDegrees,
1538
+ fovVertDegrees = fovVertDegrees,
1539
+ trackingPoor = trackingPoor,
1540
+ )
1541
+ } else {
1542
+ hybrid!!.addFrameAtPath(
1543
+ path = path!!,
1544
+ qx = qx, qy = qy, qz = qz, qw = qw,
1545
+ fx = fx, fy = fy, cx = cx, cy = cy,
1546
+ imageWidth = imageWidth, imageHeight = imageHeight,
1547
+ yaw = yaw, pitch = pitch,
1548
+ fovHorizDegrees = fovHorizDegrees,
1549
+ fovVertDegrees = fovVertDegrees,
1550
+ trackingPoor = trackingPoor,
1551
+ )
1552
+ }
1476
1553
  hybrid.snapshotIfDue(tele)
1477
1554
  }
1478
1555
  emitState(state)
@@ -1539,18 +1616,31 @@ class IncrementalStitcher(
1539
1616
  // for the full reasoning. Mirrors iOS H1.
1540
1617
  if (!frameProcessorIngestEnabled.get()) return
1541
1618
 
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)
1619
+ // F8.6 pack the full NV21 (Y + interleaved VU) once,
1620
+ // then reuse it for BOTH the gate's Y-plane read AND the
1621
+ // live engine's pixel-data ingest. Previously the plugin
1622
+ // only extracted Y; the live engine then had to JPEG-decode
1623
+ // a separately-encoded path to recover BGR colour. Now we
1624
+ // skip both round-trips: the packed NV21 → BGR cvtColor
1625
+ // inside `addFramePixelData` produces the BGR Mat directly.
1626
+ //
1627
+ // YuvImageConverter.packNV21 is stride-aware and densely
1628
+ // repacks Y (so the gate's `grayStride = grayWidth = width`
1629
+ // works), then interleaves VU per the standard NV21 layout
1630
+ // [Y...][VU...]. Returns null only on degenerate Images
1631
+ // (closed mid-callback or non-YUV format).
1632
+ val packed = io.imagestitcher.rn.ar.YuvImageConverter.packNV21(image)
1633
+ ?: return
1634
+ val width = packed.width
1635
+ val height = packed.height
1636
+ val nv21Bytes = packed.nv21
1637
+ // The gate reads `grayHeight` rows of `grayWidth` pixels
1638
+ // at stride=width starting from offset 0. That's exactly
1639
+ // the Y plane region of nv21Bytes — the gate naturally
1640
+ // stops before the UV bytes start. No need to slice into
1641
+ // a separate ByteArray.
1642
+ val yBytes = nv21Bytes
1643
+ val yRowStride = width
1554
1644
 
1555
1645
  // Compute derived params expected by the existing ingest
1556
1646
  // API. Quaternion-to-yaw/pitch follows the same convention
@@ -1596,13 +1686,22 @@ class IncrementalStitcher(
1596
1686
  grayWidth = width,
1597
1687
  grayHeight = height,
1598
1688
  grayStride = yRowStride,
1689
+ // F8.6 — pass the already-packed NV21 so the live
1690
+ // engine branch (hybrid / firstwins) can ingest via
1691
+ // `addFramePixelData` instead of JPEG-decoding a
1692
+ // separately-written path. Batch-keyframe mode
1693
+ // ignores these (it uses `grayData` + `onAccept`).
1694
+ nv21PixelData = nv21Bytes,
1695
+ nv21PixelWidth = width,
1696
+ nv21PixelHeight = height,
1599
1697
  onAccept = { targetPath ->
1600
1698
  // Synchronous JPEG encode via the existing
1601
1699
  // 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.
1700
+ // ARCore path). Reuses the NV21 already packed at
1701
+ // the top of `consumeFrameFromPlugin` F8.6 saves
1702
+ // a duplicate packNV21 call here (the previous
1703
+ // version repacked the live `image` inside the
1704
+ // lambda).
1606
1705
  //
1607
1706
  // EXIF rotation is BAKED-AS-METADATA, not pixel-
1608
1707
  // rotated. cv::imread in the stitcher ignores EXIF
@@ -1614,32 +1713,20 @@ class IncrementalStitcher(
1614
1713
  // Returning `true` tells the engine the keyframe was
1615
1714
  // persisted; `false` tells it to drop the accept.
1616
1715
  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
1716
+ val displayRotation = when (sensorRotationDegrees) {
1717
+ 0 -> android.view.Surface.ROTATION_90
1718
+ 90 -> android.view.Surface.ROTATION_0
1719
+ 180 -> android.view.Surface.ROTATION_270
1720
+ 270 -> android.view.Surface.ROTATION_180
1721
+ else -> android.view.Surface.ROTATION_0
1642
1722
  }
1723
+ val outPath = YuvImageConverter.encodeJpegFromNV21(
1724
+ packed,
1725
+ targetPath,
1726
+ jpegQuality = 80,
1727
+ displayRotation = displayRotation,
1728
+ )
1729
+ outPath != null
1643
1730
  } catch (e: Throwable) {
1644
1731
  android.util.Log.w(
1645
1732
  "IncrementalStitcher",
@@ -2548,7 +2635,114 @@ internal class IncrementalEngine(
2548
2635
  // See iOS' equivalent fix for the architectural rationale.
2549
2636
  val frame = downsampleToCompose(srcRaw)
2550
2637
  if (frame !== srcRaw) srcRaw.release()
2638
+ val tele = addFrameMat(
2639
+ frame,
2640
+ qx, qy, qz, qw,
2641
+ fx, fy, cx, cy,
2642
+ imageWidth, imageHeight,
2643
+ yaw, pitch,
2644
+ fovHorizDegrees, fovVertDegrees,
2645
+ t0,
2646
+ )
2647
+ f8_6_logPerf("hybrid/jpeg", t0, tele.outcome)
2648
+ return tele
2649
+ }
2650
+
2651
+ /**
2652
+ * F8.6 — pixel-data twin of [addFrameAtPath]. Accepts the
2653
+ * camera frame as an NV21 byte buffer instead of a JPEG file
2654
+ * path; skips the JPEG decode round-trip. See
2655
+ * `IncrementalFirstwinsEngine.addFramePixelData` for the
2656
+ * sibling implementation rationale.
2657
+ */
2658
+ fun addFramePixelData(
2659
+ nv21: ByteArray,
2660
+ nv21Width: Int,
2661
+ nv21Height: Int,
2662
+ qx: Double, qy: Double, qz: Double, qw: Double,
2663
+ fx: Double, fy: Double, cx: Double, cy: Double,
2664
+ imageWidth: Int, imageHeight: Int,
2665
+ yaw: Double, pitch: Double,
2666
+ fovHorizDegrees: Double, fovVertDegrees: Double,
2667
+ trackingPoor: Boolean,
2668
+ ): FrameTelemetry {
2669
+ val t0 = System.nanoTime()
2670
+ if (trackingPoor) {
2671
+ return FrameTelemetry(
2672
+ FrameOutcome.SkippedTrackingPoor, -1.0, 0, 0.0, 0.0,
2673
+ msSince(t0),
2674
+ )
2675
+ }
2676
+ // F8.6 IS-1 — length guard; see
2677
+ // `IncrementalFirstwinsEngine.addFramePixelData` for the
2678
+ // failure-mode rationale.
2679
+ val expectedBytes = nv21Width * nv21Height * 3 / 2
2680
+ require(nv21.size >= expectedBytes) {
2681
+ "addFramePixelData: nv21 buffer too small " +
2682
+ "(${nv21.size} bytes < $expectedBytes for " +
2683
+ "${nv21Width}x${nv21Height})"
2684
+ }
2685
+ val yuv = Mat(nv21Height + nv21Height / 2, nv21Width, CvType.CV_8UC1)
2686
+ yuv.put(0, 0, nv21)
2687
+ val srcRaw = Mat()
2688
+ Imgproc.cvtColor(yuv, srcRaw, Imgproc.COLOR_YUV2BGR_NV21)
2689
+ yuv.release()
2690
+ if (srcRaw.empty()) {
2691
+ return FrameTelemetry(
2692
+ FrameOutcome.SkippedTrackingPoor, -1.0, 0, 0.0, 0.0,
2693
+ msSince(t0),
2694
+ )
2695
+ }
2696
+ val frame = downsampleToCompose(srcRaw)
2697
+ if (frame !== srcRaw) srcRaw.release()
2698
+ val tele = addFrameMat(
2699
+ frame,
2700
+ qx, qy, qz, qw,
2701
+ fx, fy, cx, cy,
2702
+ imageWidth, imageHeight,
2703
+ yaw, pitch,
2704
+ fovHorizDegrees, fovVertDegrees,
2705
+ t0,
2706
+ )
2707
+ f8_6_logPerf("hybrid/pixel", t0, tele.outcome)
2708
+ return tele
2709
+ }
2551
2710
 
2711
+ /**
2712
+ * F8.6 perf-diagnostic counter (mirror of the firstwins one).
2713
+ * Remove after v0.5.1 ships and the numbers are baked in.
2714
+ */
2715
+ @Volatile private var f8_6_perfCallCounter: Long = 0L
2716
+ private fun f8_6_logPerf(
2717
+ path: String,
2718
+ t0Nanos: Long,
2719
+ outcome: FrameOutcome,
2720
+ ) {
2721
+ val n = ++f8_6_perfCallCounter
2722
+ if (n == 1L || n % 5L == 0L) {
2723
+ android.util.Log.i(
2724
+ "F8.6-perf",
2725
+ "$path took ${msSince(t0Nanos)}ms outcome=$outcome (call #$n)",
2726
+ )
2727
+ }
2728
+ }
2729
+
2730
+ /**
2731
+ * F8.6 — the body extracted from [addFrameAtPath]. Takes a
2732
+ * BGR `Mat` (already downsampled to compose dims) and runs the
2733
+ * pose-driven homography paste pipeline. Behaviour is
2734
+ * identical to the pre-F8.6 `addFrameAtPath` — the body is a
2735
+ * verbatim move.
2736
+ */
2737
+ private fun addFrameMat(
2738
+ frame: Mat,
2739
+ qx: Double, qy: Double, qz: Double, qw: Double,
2740
+ fx: Double, fy: Double, cx: Double, cy: Double,
2741
+ imageWidth: Int, imageHeight: Int,
2742
+ yaw: Double, pitch: Double,
2743
+ fovHorizDegrees: Double, fovVertDegrees: Double,
2744
+ t0: Long,
2745
+ ): FrameTelemetry {
2552
2746
  // Build R_new from quaternion.
2553
2747
  val rNew = quaternionToRotationMat(qx, qy, qz, qw)
2554
2748
 
@@ -140,6 +140,24 @@ export interface CameraProps {
140
140
  enablePanoramaMode?: boolean;
141
141
  showSettingsButton?: boolean;
142
142
  style?: StyleProp<ViewStyle>;
143
+ /**
144
+ * Which incremental stitcher engine to drive. Default
145
+ * `'batch-keyframe'` — collects accepted JPEGs and runs
146
+ * `cv::Stitcher` once at finalize time. This is the v0.4+
147
+ * production default and what the v0.5 Frame Processor migration
148
+ * exercises.
149
+ *
150
+ * Switch to a live engine (`'firstwins-rectilinear'` or
151
+ * `'hybrid'`) for low-latency in-flight stitching. Live engines
152
+ * exercise the F8.6 pixel-buffer ingest path (skipping the JPEG
153
+ * encode/decode round-trip; ~30–50 ms saved per accept) when the
154
+ * Frame Processor driver is active.
155
+ *
156
+ * See `docs/f8-frame-processor-plan.md` and the v0.5.0
157
+ * CHANGELOG for the trade-offs between batch-keyframe and live
158
+ * engines.
159
+ */
160
+ engine?: 'batch-keyframe' | 'hybrid' | 'slitscan-rotate' | 'slitscan-both' | 'firstwins' | 'firstwins-zoomed' | 'firstwins-rectilinear' | 'slitscan';
143
161
  /**
144
162
  * Optional destination directory for captures. When set, the lib
145
163
  * lands tap-photos at `${outputDir}/photo-${ts}.jpg` and panoramas
@@ -271,7 +271,7 @@ function extractPanoramaOverrides(props) {
271
271
  * The public `<Camera>` component.
272
272
  */
273
273
  function Camera(props) {
274
- const { defaultCaptureSource = 'ar', defaultLens = '1x', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, outputDir, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, frameProcessor: hostFrameProcessor, legacyDriver = false, } = props;
274
+ const { defaultCaptureSource = 'ar', defaultLens = '1x', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, outputDir, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, frameProcessor: hostFrameProcessor, legacyDriver = false, engine = 'batch-keyframe', } = props;
275
275
  const insets = (0, react_native_safe_area_context_1.useSafeAreaInsets)();
276
276
  // ── State ───────────────────────────────────────────────────────
277
277
  const [arPreference, setArPreference] = (0, react_1.useState)(defaultCaptureSource === 'ar');
@@ -662,7 +662,7 @@ function Camera(props) {
662
662
  composeHeight: 1080,
663
663
  canvasWidth: 5000,
664
664
  canvasHeight: 5000,
665
- engine: 'batch-keyframe',
665
+ engine,
666
666
  config: (0, PanoramaSettingsBridge_1.panoramaSettingsToNativeConfig)({
667
667
  ...settings,
668
668
  captureSource: effectiveCaptureSource,
@@ -706,6 +706,7 @@ function Camera(props) {
706
706
  jsDriver,
707
707
  fpDriver,
708
708
  legacyDriver,
709
+ engine,
709
710
  onError,
710
711
  ]);
711
712
  const handleHoldEnd = (0, react_1.useCallback)(async () => {
package/ios/Package.swift CHANGED
@@ -1,4 +1,4 @@
1
- // swift-tools-version:5.9
1
+ // swift-tools-version:5.10
2
2
  //
3
3
  // Package.swift — SwiftPM manifest used **only for command-line testing**
4
4
  // of the algorithm layer (QualityChecker.swift). Production builds
@@ -38,26 +38,40 @@ let package = Package(
38
38
  .target(
39
39
  name: "RNImageStitcher",
40
40
  path: "Sources/RNImageStitcher",
41
- // Excluded from `swift test` because they depend on either
42
- // React (which isn't a SwiftPM dep) or OpenCV (which only
43
- // ships as an iOS XCFramework via the podspec — no macOS
44
- // build). The host app's CocoaPods workspace picks them up.
45
- exclude: [
46
- // React-dependent
47
- "QualityCheckerBridge.swift",
48
- "QualityCheckerBridge.m",
49
- "StitcherBridge.swift",
50
- "StitcherBridge.m",
51
- // OpenCV-dependent (Phase 2 stitcher)
52
- "OpenCVStitcher.h",
53
- "OpenCVStitcher.mm",
54
- // OpenCV-dependent (V16 Phase 1 keyframe collector)
55
- "OpenCVKeyframeCollector.h",
56
- "OpenCVKeyframeCollector.mm",
57
- // Stitcher.swift is `#if canImport(UIKit)`-gated so it
58
- // compiles to nothing on macOS; including it keeps the
59
- // file available to the Pods build without breaking
60
- // `swift test`.
41
+ // F8.3.H2-target instead of an `exclude` list (which broke
42
+ // every time a new .mm landed, e.g.
43
+ // `KeyframeGateFrameProcessor.mm` in F8.1, because SwiftPM
44
+ // still scans the directory and rejects "mixed language
45
+ // source files" if it sees both .swift and .mm), we use an
46
+ // explicit `sources` allowlist of files that compile cleanly
47
+ // on macOS (where `swift test` runs).
48
+ //
49
+ // What's in the allowlist:
50
+ // * QualityChecker.swift — Accelerate / CoreImage; macOS-OK.
51
+ // * KeyframeGate.swift Foundation + simd; macOS-OK.
52
+ //
53
+ // What's NOT (intentionally):
54
+ // * Anything with `import UIKit` / `import ARKit` — iOS only.
55
+ // CocoaPods compiles them for the host app via the podspec
56
+ // source_files glob; SwiftPM macOS doesn't need them.
57
+ // * .mm / .m / .h files — same. Picked up by CocoaPods.
58
+ // * RN-bridge Swift files (`*Bridge.swift`) `import React`,
59
+ // not a SwiftPM dep.
60
+ //
61
+ // The Frame Processor plugin's Swift⇄ObjC selector pin
62
+ // (formerly relied on by `FrameProcessorPluginSelectorTests`)
63
+ // is enforced as a compile-time `#selector(...)` reference
64
+ // inside `IncrementalStitcher.swift` itself — see the
65
+ // `_consumeFrameFromPluginSelectorPin` static. Drift breaks
66
+ // the SDK build, which is a stronger guarantee than a test
67
+ // that needs iOS-Simulator infrastructure to run.
68
+ sources: [
69
+ "QualityChecker.swift",
70
+ // KeyframeGate.swift depends on `KeyframeGateBridge` (ObjC
71
+ // class in .mm) and `RNSARFramePose` (from a UIKit-using
72
+ // Swift file), so it doesn't compile standalone under
73
+ // SwiftPM on macOS — only the CocoaPods build sees the
74
+ // full type graph.
61
75
  ]
62
76
  ),
63
77
  .testTarget(
@@ -476,6 +476,13 @@ public final class IncrementalStitcher: NSObject {
476
476
 
477
477
  private override init() {
478
478
  super.init()
479
+ // F8.3.H2 — runtime check that Swift's auto-bridged ObjC
480
+ // selector for `consumeFrameFromPlugin(...)` matches the
481
+ // selector string the plugin's .mm dispatches. Asserts in
482
+ // dev builds; no-ops in release. See the
483
+ // `_consumeFrameFromPluginSelectorPin` declaration below for
484
+ // the full rationale.
485
+ IncrementalStitcher._verifyConsumeFrameFromPluginSelector()
479
486
  }
480
487
 
481
488
  /// 2026-05-18 (iOS cross-orientation fix) — bridge entry-point
@@ -3158,4 +3165,57 @@ extension IncrementalStitcher {
3158
3165
  )
3159
3166
  consumeFrame(pixelBuffer: pixelBuffer, pose: pose)
3160
3167
  }
3168
+
3169
+ // F8.3.H2 — compile-time + runtime guard for the Swift⇄ObjC
3170
+ // selector contract that `KeyframeGateFrameProcessor.mm`
3171
+ // depends on.
3172
+ //
3173
+ // The .mm file forward-declares `IncrementalStitcher` and
3174
+ // dispatches `[shared consumeFrameFromPluginWithPixelBuffer:tx:
3175
+ // …:trackingStateRaw:]` by NAME — ObjC's late-binding means
3176
+ // signature drift would silently link but crash at runtime
3177
+ // with `NSInvalidArgumentException: unrecognized selector`
3178
+ // on the first non-AR frame.
3179
+ //
3180
+ // This `#selector(...)` reference forces the Swift compiler
3181
+ // to resolve the exact method signature. If anyone renames a
3182
+ // parameter label or adds/removes an argument, the
3183
+ // `_consumeFrameFromPluginSelectorPin` expression fails to
3184
+ // compile — the SDK won't build until the .mm's forward
3185
+ // declaration is updated to match. Stronger guarantee than a
3186
+ // test that needs iOS-Simulator infrastructure to run.
3187
+ //
3188
+ // The runtime check below additionally pins the exact
3189
+ // SELECTOR STRING the .mm dispatches. In dev/debug builds it
3190
+ // asserts; in release builds it's a no-op (the static let is
3191
+ // initialised lazily and never read otherwise, so the runtime
3192
+ // cost is one-time + tiny). Drift between Swift's auto-
3193
+ // generated selector name and the .mm's expected string
3194
+ // (e.g., if Swift's bridging rules change) trips the assert.
3195
+ private static let _consumeFrameFromPluginSelectorPin: Selector =
3196
+ #selector(IncrementalStitcher.consumeFrameFromPlugin(
3197
+ pixelBuffer:
3198
+ tx: ty: tz:
3199
+ qx: qy: qz: qw:
3200
+ fx: fy: cx: cy:
3201
+ imageWidth: imageHeight:
3202
+ timestampMs:
3203
+ trackingStateRaw:))
3204
+
3205
+ @inline(never)
3206
+ private static func _verifyConsumeFrameFromPluginSelector() {
3207
+ let expected =
3208
+ "consumeFrameFromPluginWithPixelBuffer:tx:ty:tz:"
3209
+ + "qx:qy:qz:qw:fx:fy:cx:cy:"
3210
+ + "imageWidth:imageHeight:timestampMs:trackingStateRaw:"
3211
+ let actual = NSStringFromSelector(_consumeFrameFromPluginSelectorPin)
3212
+ assert(
3213
+ actual == expected,
3214
+ "Frame Processor selector drift — Swift's auto-bridged "
3215
+ + "ObjC selector for consumeFrameFromPlugin is "
3216
+ + "\(actual) but KeyframeGateFrameProcessor.mm's "
3217
+ + "forward declaration expects \(expected). Update the "
3218
+ + ".mm to match (or fix the assumption here).",
3219
+ )
3220
+ }
3161
3221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -235,6 +235,29 @@ export interface CameraProps {
235
235
  showSettingsButton?: boolean;
236
236
  style?: StyleProp<ViewStyle>;
237
237
 
238
+ /**
239
+ * Which incremental stitcher engine to drive. Default
240
+ * `'batch-keyframe'` — collects accepted JPEGs and runs
241
+ * `cv::Stitcher` once at finalize time. This is the v0.4+
242
+ * production default and what the v0.5 Frame Processor migration
243
+ * exercises.
244
+ *
245
+ * Switch to a live engine (`'firstwins-rectilinear'` or
246
+ * `'hybrid'`) for low-latency in-flight stitching. Live engines
247
+ * exercise the F8.6 pixel-buffer ingest path (skipping the JPEG
248
+ * encode/decode round-trip; ~30–50 ms saved per accept) when the
249
+ * Frame Processor driver is active.
250
+ *
251
+ * See `docs/f8-frame-processor-plan.md` and the v0.5.0
252
+ * CHANGELOG for the trade-offs between batch-keyframe and live
253
+ * engines.
254
+ */
255
+ engine?: 'batch-keyframe'
256
+ | 'hybrid'
257
+ | 'slitscan-rotate' | 'slitscan-both'
258
+ | 'firstwins' | 'firstwins-zoomed' | 'firstwins-rectilinear'
259
+ | 'slitscan';
260
+
238
261
  /**
239
262
  * Optional destination directory for captures. When set, the lib
240
263
  * lands tap-photos at `${outputDir}/photo-${ts}.jpg` and panoramas
@@ -587,6 +610,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
587
610
  onError,
588
611
  frameProcessor: hostFrameProcessor,
589
612
  legacyDriver = false,
613
+ engine = 'batch-keyframe',
590
614
  } = props;
591
615
 
592
616
  const insets = useSafeAreaInsets();
@@ -1031,7 +1055,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
1031
1055
  composeHeight: 1080,
1032
1056
  canvasWidth: 5000,
1033
1057
  canvasHeight: 5000,
1034
- engine: 'batch-keyframe',
1058
+ engine,
1035
1059
  config: panoramaSettingsToNativeConfig({
1036
1060
  ...settings,
1037
1061
  captureSource: effectiveCaptureSource,
@@ -1079,6 +1103,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
1079
1103
  jsDriver,
1080
1104
  fpDriver,
1081
1105
  legacyDriver,
1106
+ engine,
1082
1107
  onError,
1083
1108
  ]);
1084
1109