react-native-image-stitcher 0.5.0 → 0.6.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,177 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.6.0] — 2026-05-25
20
+
21
+ > [!WARNING]
22
+ > **Breaking changes.** v0.6.0 retires the deprecated JS-driver
23
+ > non-AR path that was marked for removal in v0.5.0's *Deprecated*
24
+ > section. Hosts using the default `<Camera>` flow (`legacyDriver`
25
+ > unset) are not affected — they were already on
26
+ > `useFrameProcessorDriver`. Hosts that opted into the legacy
27
+ > driver (`legacyDriver={true}` on `<Camera>`, or a direct
28
+ > `useIncrementalJSDriver()` consumer) MUST migrate to the Frame
29
+ > Processor driver — see *Migration from 0.5.x* below.
30
+
31
+ ### Removed (breaking)
32
+
33
+ - **`useIncrementalJSDriver` hook** + its `UseIncrementalJSDriverOptions`
34
+ / `IncrementalJSDriverHandle` types. Deprecated in v0.5.0; the
35
+ v0.5 deprecation warning has now been replaced by deletion.
36
+ - **`legacyDriver?: boolean` prop on `<Camera>`**. The escape hatch
37
+ back to the JS driver is gone. Hosts that set this prop will
38
+ get a TS-level error; at runtime the prop is silently ignored.
39
+ - **`frameSourceMode: 'jsDriver'`** enum value in
40
+ `IncrementalStartOptions`. The TS type is now narrowed to
41
+ `'arSession' | 'frameProcessor'`. Passing `'jsDriver'` is a
42
+ compile error; at the native bridge layer the value falls through
43
+ to the default (now `'arSession'`).
44
+ - **`IncrementalStitcher.processFrameAtPath` native method** on both
45
+ iOS and Android. The only JS caller was `useIncrementalJSDriver`,
46
+ also deleted. Hosts calling
47
+ `NativeModules.IncrementalStitcher.processFrameAtPath(...)` via
48
+ raw `NativeModules` access will get a runtime "method does not
49
+ exist" error. Use the Frame Processor driver instead.
50
+
51
+ ### Changed (breaking)
52
+
53
+ - **Android `frameSourceMode` default switched from `"jsDriver"` to
54
+ `"arSession"`** for parity with iOS. Raw `NativeModules` callers
55
+ that omitted `frameSourceMode` were previously getting an inert
56
+ capture (the "jsDriver" branch dropped all engine input on
57
+ Android since v0.5.0); they now get AR-mode behaviour, matching
58
+ iOS. The production `<Camera>` is unaffected — it always passes
59
+ `frameSourceMode: 'arSession'` explicitly for AR captures.
60
+
61
+ ### Changed (non-breaking)
62
+
63
+ - **`RNSARCameraView` (AR mode) no longer eager-encodes a JPEG per
64
+ ARCore frame.** Migrated to the pixel-data path introduced for
65
+ the Frame Processor in v0.5.1's F8.6 work. AR-mode captures now
66
+ pass `nv21PixelData` / `nv21PixelWidth` / `nv21PixelHeight`
67
+ through `ingestFromARCameraView`; `legacyJpegPath` is always null
68
+ on this path. Expected gain on Galaxy A35: ~30-50 ms per
69
+ accepted frame, with the dominant savings on rejected frames
70
+ (no JPEG encode → no imread round-trip). Closes the v0.5.0
71
+ follow-up.
72
+
73
+ ### Removed (internal cleanup; no external API impact)
74
+
75
+ - **F8.6 perf-diagnostic logs** (`F8.6-route`, `F8.6-perf`)
76
+ introduced in v0.5.1 stripped from `IncrementalStitcher` +
77
+ `IncrementalFirstwinsEngine` — F8.6 is now baked in for
78
+ production and the diagnostic spam is no longer informative.
79
+ - **Orphaned native helpers** dropped after `processFrameAtPath`
80
+ removal:
81
+ - iOS: `addBatchKeyframePath(path:pose:)`, `isBatchKeyframeMode`
82
+ getter, `decodeJpegToGrayscalePixelBuffer` (only callers were
83
+ `processFrameAtPath`).
84
+ - Android: `decodeJpegToGrayscale` + `GrayscaleFrame` data class,
85
+ `isBatchKeyframeMode` getter (only callers were
86
+ `processFrameAtPath` and the AR-mode eager-encode branch).
87
+ - **Stale comments** referencing removed code paths swept across
88
+ Kotlin/Swift/Obj-C/TS. Historical "removed in v0.6" markers
89
+ retained; comments that described live code in terms of the
90
+ removed names rewritten to describe current behaviour.
91
+
92
+ ### Migration from 0.5.x
93
+
94
+ **Default `<Camera>` hosts (no `legacyDriver` prop set):** no
95
+ action required. `<Camera>` already used `useFrameProcessorDriver`
96
+ in non-AR mode and `RNSARSession` in AR mode since v0.5.0.
97
+
98
+ **Hosts with `legacyDriver={true}` on `<Camera>`:** remove the
99
+ prop. `<Camera>` will use the Frame Processor driver, which has
100
+ been the default since v0.5.0 and the only path since this release.
101
+
102
+ ```tsx
103
+ // Before (v0.5.x)
104
+ <Camera legacyDriver={true} ... />
105
+
106
+ // After (v0.6.0)
107
+ <Camera ... />
108
+ ```
109
+
110
+ **Hosts directly using `useIncrementalJSDriver`:** migrate to
111
+ `useFrameProcessorDriver`. The handle shape (`{ start, stop,
112
+ frameProcessor, isRunning }`) is preserved, but the new hook is a
113
+ Frame Processor + gyro driver instead of a `takeSnapshot` + JS
114
+ interval driver. See
115
+ [`src/stitching/useFrameProcessorDriver.ts`](src/stitching/useFrameProcessorDriver.ts)
116
+ for the migration mapping; the gyro pose synthesis convention
117
+ (`q = q_yaw * q_pitch * q_roll`) is identical, so existing pose
118
+ math at call sites continues to work.
119
+
120
+ **Hosts passing `frameSourceMode: 'jsDriver'` to
121
+ `incremental.start(...)`:** change to `'frameProcessor'`. The
122
+ TypeScript type now rejects `'jsDriver'` at compile time.
123
+
124
+ ## [0.5.1] — 2026-05-25
125
+
126
+ ### Added — F8.6 Android pixel-buffer engine parity
127
+
128
+ Closes the v0.5.0 follow-up tracked in the [0.5.0] section.
129
+
130
+ **Live engine ingest no longer requires a JPEG round-trip.**
131
+ The `IncrementalFirstwinsEngine` (slit-scan / first-wins) and the
132
+ hybrid `IncrementalEngine` both gained a new
133
+ `addFramePixelData(nv21, w, h, ...)` method. It builds the BGR
134
+ `cv::Mat` in-process via
135
+ `Imgproc.cvtColor(yuv, COLOR_YUV2BGR_NV21)`, then delegates to a
136
+ newly-extracted shared `addFrameMat` helper that runs the original
137
+ engine pipeline verbatim. The legacy `addFrameAtPath(path, ...)`
138
+ is now a thin wrapper: `imread → downsample → addFrameMat`.
139
+
140
+ **Routing.** `IncrementalStitcher.ingestFromARCameraView` got
141
+ three optional parameters — `nv21PixelData: ByteArray?`,
142
+ `nv21PixelWidth: Int`, `nv21PixelHeight: Int`. When supplied (and
143
+ `batchKeyframeMode == false`), the live engine ingests via
144
+ `addFramePixelData`; otherwise falls back to `addFrameAtPath` with
145
+ `legacyJpegPath`. Backwards-compatible — all-null defaults
146
+ preserve every existing caller.
147
+
148
+ **Frame Processor wiring.** `consumeFrameFromPlugin` now packs the
149
+ incoming `Image` NV21 once at the top (was twice — gate consumed
150
+ Y only, then the `onAccept` lambda re-packed for JPEG encode) and
151
+ threads the bytes through to both the gate (which reads only the
152
+ Y subset) AND the new `nv21PixelData` parameter. Net: single
153
+ `packNV21` per producer-thread frame.
154
+
155
+ **Measured on Galaxy A35, `engine: 'firstwins-rectilinear'`,
156
+ non-AR Frame Processor capture:**
157
+
158
+ | Outcome | F8.6 pixel-data | Legacy JPEG path (estimated) |
159
+ |---|---|---|
160
+ | `AcceptedHigh` (first-frame init) | 7–11 ms | 50–70 ms |
161
+ | `SkippedTooClose` (gate bail) | 0.5–2 ms | 50–60 ms (imread is unconditional) |
162
+
163
+ `SkippedTooClose` dominates the producer-thread frame budget
164
+ (~95% of frames at 30 fps with a slow pan). Eliminating the
165
+ imread on those frames is the bulk of the F8.6 win.
166
+
167
+ ### Added
168
+
169
+ * New `<Camera engine={...}>` prop exposes the live engine
170
+ selection (`'batch-keyframe'` (default) / `'firstwins-rectilinear'`
171
+ / `'hybrid'` / `'slitscan-*'`). Lets hosts opt into in-flight
172
+ stitching for low-latency previews; previously the choice was
173
+ hardcoded.
174
+
175
+ ### Changed
176
+
177
+ * `New: F8.6 perf-diagnostic logs` (`F8.6-route`, `F8.6-perf`) fire
178
+ in live-engine mode only — inert under the default
179
+ `batch-keyframe`. Will be removed in v0.6 once F8.6 is baked in
180
+ production.
181
+
182
+ ### Fixed
183
+
184
+ * In `IncrementalStitcher.consumeFrameFromPlugin`, the `onAccept`
185
+ lambda was re-packing the live `Image` instead of reusing the
186
+ already-packed NV21 from the outer scope. Now it reuses the
187
+ outer `packed` — saves a redundant `packNV21` call on every
188
+ accepted frame.
189
+
19
190
  ## [0.5.0] — 2026-05-25
20
191
 
21
192
  ### Added — F8 Frame Processor port
@@ -91,13 +262,29 @@ Frame Processor** on the camera producer thread instead of the
91
262
 
92
263
  ### Tracking — known follow-ups (don't gate this release)
93
264
 
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.
265
+ - **F8.6 (v0.5.1)** — Android engine refactor for pixel-buffer-
266
+ direct ingest (true zero-copy parity with iOS). Would extract
267
+ an `addFrameMat` helper from `IncrementalFirstwinsEngine` and
268
+ `IncrementalEngine`'s `addFrameAtPath`, add a parallel
269
+ `addFramePixelData` that constructs the BGR `cv::Mat` from NV21
270
+ bytes via `cvtColor`, and rewire `RNSARCameraView` to skip the
271
+ per-frame JPEG encode. Expected gain: ~30–50 ms per accepted
272
+ frame. Deferred because the engine bodies are 400+ lines of
273
+ complex AR-mode code; needs A35 device verification before
274
+ merge, which the v0.5.0 prep session didn't have.
275
+
276
+ - **F8.3-followup-roll** — resolved in v0.5.0.
277
+
278
+ - **F8.3.H2-target** — RESOLVED in v0.5.0 via a different
279
+ mechanism than originally planned. The selector pin is now a
280
+ compile-time `#selector(...)` reference inside
281
+ `IncrementalStitcher.swift` plus a dev-build runtime assert in
282
+ `IncrementalStitcher.init()` — both fire if the Swift method
283
+ signature drifts from what `KeyframeGateFrameProcessor.mm`
284
+ expects. The obsolete test file was deleted. `swift test` now
285
+ runs the (8-test) `QualityCheckerTests` suite cleanly because
286
+ `Package.swift` switched from an exclude list (broke every time
287
+ a new `.mm` landed) to an explicit `sources` allowlist.
101
288
 
102
289
  ## [0.4.1] — 2026-05-23
103
290
 
@@ -974,7 +1161,12 @@ Native module names also changed:
974
1161
  - iOS pod: `RetaiLensCaptureSDK` → `RNImageStitcher`
975
1162
  - iOS xcframework: shipped as `opencv2.xcframework` (linked from `RNImageStitcher.podspec`)
976
1163
 
977
- [Unreleased]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.3.0...HEAD
1164
+ [Unreleased]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.6.0...HEAD
1165
+ [0.6.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.5.1...v0.6.0
1166
+ [0.5.1]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.5.0...v0.5.1
1167
+ [0.5.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.4.1...v0.5.0
1168
+ [0.4.1]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.4.0...v0.4.1
1169
+ [0.4.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.3.0...v0.4.0
978
1170
  [0.3.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.2.1...v0.3.0
979
1171
  [0.2.1]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.2.0...v0.2.1
980
1172
  [0.2.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.1.3...v0.2.0
@@ -29,8 +29,8 @@ import com.mrousavy.camera.frameprocessors.VisionCameraProxy
29
29
  * 3. Call `IncrementalStitcher.consumeFrameFromPlugin(image, …)`
30
30
  * which:
31
31
  * - Drops the call if `frameSourceMode != "frameProcessor"`
32
- * (prevents double-feeding the engine alongside the legacy
33
- * `processFrameAtPath` path).
32
+ * (prevents double-feeding the engine alongside the
33
+ * AR-mode `ingestFromARCameraView` path).
34
34
  * - Otherwise: extracts the Y plane, evaluates the keyframe
35
35
  * gate via `KeyframeGate.evaluateWithFrame`, encodes the
36
36
  * accepted frame to JPEG synchronously, and hands the path
@@ -197,7 +197,127 @@ internal class IncrementalFirstwinsEngine(
197
197
  }
198
198
  val frameBGR = downsampleToCompose(srcRaw)
199
199
  if (frameBGR !== srcRaw) srcRaw.release()
200
+ return 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
+ }
210
+
211
+ /**
212
+ * F8.6 — pixel-data twin of [addFrameAtPath]. Accepts the
213
+ * camera frame as an NV21 byte buffer instead of a JPEG file
214
+ * path; skips the JPEG decode round-trip (~30–50 ms per
215
+ * accepted frame on a mid-tier device).
216
+ *
217
+ * Use this from the vision-camera Frame Processor path (where
218
+ * we already have direct producer-thread access to YUV bytes)
219
+ * and from the ARCore path (where the previous
220
+ * JPEG-encode-on-every-frame in `RNSARCameraView` was a
221
+ * measurable hot-spot).
222
+ *
223
+ * The body matches [addFrameAtPath] one-for-one except for the
224
+ * Mat construction: an `Mat(h*3/2, w, CV_8UC1)` wraps the
225
+ * NV21 bytes, then `Imgproc.cvtColor` produces the BGR Mat the
226
+ * engine pipeline already expects. Everything downstream is
227
+ * the shared [addFrameMat] helper.
228
+ *
229
+ * `nv21Width`/`nv21Height` describe the buffer's actual
230
+ * dimensions. `imageWidth`/`imageHeight` describe the
231
+ * camera's reported sensor dims used for intrinsics scaling —
232
+ * these can differ when the camera is downsampling for Frame
233
+ * Processor output.
234
+ */
235
+ fun addFramePixelData(
236
+ nv21: ByteArray,
237
+ nv21Width: Int,
238
+ nv21Height: Int,
239
+ qx: Double, qy: Double, qz: Double, qw: Double,
240
+ fx: Double, fy: Double, cx: Double, cy: Double,
241
+ imageWidth: Int, imageHeight: Int,
242
+ yaw: Double, pitch: Double,
243
+ fovHorizDegrees: Double, fovVertDegrees: Double,
244
+ trackingPoor: Boolean,
245
+ ): FrameTelemetry {
246
+ val t0 = System.nanoTime()
247
+ if (trackingPoor) {
248
+ return FrameTelemetry(
249
+ FrameOutcome.SkippedTrackingPoor, -1.0, 0, yaw, pitch,
250
+ msSince(t0),
251
+ isLandscape = isLandscape,
252
+ )
253
+ }
254
+ // NV21 layout: Y plane (w*h bytes) + interleaved VU
255
+ // (w*h/2 bytes). A single CV_8UC1 Mat of height h*3/2
256
+ // packs the whole thing; cvtColor with COLOR_YUV2BGR_NV21
257
+ // does the planar-aware decode in one call.
258
+ //
259
+ // F8.6 IS-1 — length guard. If the caller supplied a
260
+ // short buffer, `yuv.put(0,0,nv21)` would copy only
261
+ // `nv21.size` bytes and leave the rest zero-init; cvtColor
262
+ // would then read stale/zero UV and produce silently
263
+ // corrupt colour. Fail fast instead.
264
+ val expectedBytes = nv21Width * nv21Height * 3 / 2
265
+ require(nv21.size >= expectedBytes) {
266
+ "addFramePixelData: nv21 buffer too small " +
267
+ "(${nv21.size} bytes < $expectedBytes for " +
268
+ "${nv21Width}x${nv21Height})"
269
+ }
270
+ val yuv = Mat(nv21Height + nv21Height / 2, nv21Width, CvType.CV_8UC1)
271
+ yuv.put(0, 0, nv21)
272
+ val srcRaw = Mat()
273
+ Imgproc.cvtColor(yuv, srcRaw, Imgproc.COLOR_YUV2BGR_NV21)
274
+ yuv.release()
275
+ if (srcRaw.empty()) {
276
+ return FrameTelemetry(
277
+ FrameOutcome.RejectedAlignmentLost, -1.0, 0, yaw, pitch,
278
+ msSince(t0),
279
+ isLandscape = isLandscape,
280
+ )
281
+ }
282
+ val frameBGR = downsampleToCompose(srcRaw)
283
+ if (frameBGR !== srcRaw) srcRaw.release()
284
+ return addFrameMat(
285
+ frameBGR,
286
+ qx, qy, qz, qw,
287
+ fx, fy, cx, cy,
288
+ imageWidth, imageHeight,
289
+ yaw, pitch,
290
+ fovHorizDegrees, fovVertDegrees,
291
+ t0,
292
+ )
293
+ }
200
294
 
295
+ /**
296
+ * F8.6 — the body extracted from [addFrameAtPath]. Takes a
297
+ * BGR `Mat` (downsampled to compose dims) and runs the full
298
+ * engine pipeline: first-frame init or subsequent-frame paste
299
+ * via either rectilinear or cylindrical warp.
300
+ *
301
+ * Behaviour is identical to the pre-F8.6 `addFrameAtPath`
302
+ * (the body is a verbatim move). Both `addFrameAtPath` and
303
+ * `addFramePixelData` delegate here after their respective
304
+ * Mat constructions.
305
+ */
306
+ private fun addFrameMat(
307
+ frameBGR: Mat,
308
+ qx: Double, qy: Double, qz: Double, qw: Double,
309
+ fx: Double, fy: Double, cx: Double, cy: Double,
310
+ imageWidth: Int, imageHeight: Int,
311
+ yaw: Double, pitch: Double,
312
+ // FOV params kept for symmetry with `IncrementalEngine.addFrameMat`
313
+ // (the hybrid engine, which uses them in `computeOverlapPct`).
314
+ // The firstwins/slit-scan engine here doesn't consume them —
315
+ // its paste decision is driven by pose-projected pixel
316
+ // displacement, not FoV-overlap percent.
317
+ @Suppress("UNUSED_PARAMETER") fovHorizDegrees: Double,
318
+ @Suppress("UNUSED_PARAMETER") fovVertDegrees: Double,
319
+ t0: Long,
320
+ ): FrameTelemetry {
201
321
  val rNew = quaternionToRotationMat(qx, qy, qz, qw)
202
322
 
203
323
  if (!hasFirstFrame) {