react-native-image-stitcher 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,25 @@ import java.io.FileOutputStream
14
14
  /**
15
15
  * Convert an ARCore `Image` (YUV_420_888) to a JPEG file on disk.
16
16
  *
17
+ * 2026-05-22 (audit follow-up #19) — split into two phases so callers
18
+ * can release the underlying ARCore `Image` ASAP:
19
+ *
20
+ * 1. `packNV21(image)` — reads the Y/U/V planes into a contiguous
21
+ * JVM-side `ByteArray` (NV21 layout). Fast (~3 ms for 1920×1080).
22
+ * The caller can close the `Image` IMMEDIATELY after this returns,
23
+ * freeing the ARCore Camera2 ImageReader buffer.
24
+ *
25
+ * 2. `encodeJpegFromNV21(packed, …)` — does the slow YUV→JPEG
26
+ * conversion (~10-25 ms) on the already-extracted bytes, NOT on
27
+ * the Image. Safe to run after the Image has been closed.
28
+ *
29
+ * The pre-#19 single-call `encodeToJpeg(image, …)` API is preserved as
30
+ * a thin wrapper for callers that don't care about Image hold time
31
+ * (e.g., one-shot photo capture). Performance-critical paths
32
+ * (`RNSARCameraView.forwardToIncremental`, called at ~60 Hz on the
33
+ * GL render thread) should use the two-step API to keep Image hold
34
+ * times bounded by the ~3 ms pack step instead of the ~25 ms encode.
35
+ *
17
36
  * Why JPEG → file → re-decode by OpenCV (slightly wasteful)?
18
37
  * The incremental engine's existing API (matching iOS') consumes
19
38
  * image PATHS, not raw planes. Threading raw YUV through the
@@ -22,56 +41,182 @@ import java.io.FileOutputStream
22
41
  * next to the ~40 ms per-frame engine work — keeping the surface
23
42
  * uniform across iOS / Android paths is worth the few-ms cost.
24
43
  *
25
- * `Image` ownership: caller MUST `image.close()` after this returns.
26
- * We don't close inside because the caller may want to inspect more
27
- * fields (timestamp, format) before releasing.
44
+ * `Image` ownership: the two-step API (`packNV21` + `encodeJpegFromNV21`)
45
+ * returns control to the caller after the pack step so the caller can
46
+ * close the Image at the right moment. The legacy single-call
47
+ * `encodeToJpeg(image, …)` does NOT close the Image — caller is
48
+ * responsible for that.
28
49
  */
29
50
  internal object YuvImageConverter {
30
51
 
31
- /// Convert + write JPEG. Returns the path (no file:// prefix)
32
- /// or null on any encode/write error (caller-decides whether to
33
- /// log + drop the frame).
34
- ///
35
- /// 2026-05-15 (B3) — `displayRotation` parameter writes the
36
- /// appropriate EXIF orientation tag so RN's Image loader (and
37
- /// any other consumer that respects EXIF) displays the JPEG
38
- /// upright regardless of how the device was held at capture.
39
- ///
40
- /// Without this, Android's image loaders display the raw sensor
41
- /// pixels (typically landscape) as-is — so a portrait-held
42
- /// capture's thumbnail appears sideways. iOS's image loader
43
- /// auto-respects EXIF orientation; Android's doesn't always
44
- /// (depends on the loader path). Setting the tag covers both.
45
- ///
46
- /// `displayRotation` should be the value returned from
47
- /// `WindowManager.defaultDisplay.rotation` at capture time
48
- /// (Surface.ROTATION_0/_90/_180/_270). We assume a typical
49
- /// back-camera sensor orientation of 90° — true for all
50
- /// devices we ship to (Galaxy A35 verified). Wire through
51
- /// CameraCharacteristics.SENSOR_ORIENTATION in a follow-up
52
- /// if we ever encounter a device that differs.
53
- ///
54
- /// Default `displayRotation = Surface.ROTATION_0` (portrait)
55
- /// preserves the previous behaviour for legacy callsites that
56
- /// haven't been updated yet.
57
- fun encodeToJpeg(
58
- image: Image,
52
+ /**
53
+ * Packed NV21 pixel data extracted from an ARCore `Image`.
54
+ * Once you hold one of these, the source `Image` can be closed —
55
+ * all subsequent operations work on the JVM-side byte array.
56
+ *
57
+ * NV21 layout (single contiguous byte array):
58
+ * bytes [0 .. width*height) = Y plane (luminance),
59
+ * densely packed,
60
+ * row stride = width
61
+ * bytes [width*height .. width*height*3/2) = interleaved V-U pairs
62
+ * at half resolution
63
+ *
64
+ * The Y plane portion can be passed directly to the C++
65
+ * `keyframe_gate` as grayscale pixels with `stride = width`.
66
+ */
67
+ data class PackedYuv(
68
+ val nv21: ByteArray,
69
+ val width: Int,
70
+ val height: Int,
71
+ ) {
72
+ /** Length of the Y plane portion (bytes [0 .. ySize)). */
73
+ val ySize: Int get() = width * height
74
+
75
+ // equals + hashCode override required because `nv21` is a
76
+ // mutable array; default `data class` equality uses reference
77
+ // identity for arrays, which is rarely what callers want.
78
+ override fun equals(other: Any?): Boolean {
79
+ if (this === other) return true
80
+ if (other !is PackedYuv) return false
81
+ return width == other.width
82
+ && height == other.height
83
+ && nv21.contentEquals(other.nv21)
84
+ }
85
+ override fun hashCode(): Int {
86
+ var result = nv21.contentHashCode()
87
+ result = 31 * result + width
88
+ result = 31 * result + height
89
+ return result
90
+ }
91
+ }
92
+
93
+
94
+ /**
95
+ * Pack the Y, U, V planes of a YUV_420_888 `Image` into a
96
+ * contiguous JVM-side NV21 byte array. Returns null if the
97
+ * `Image`'s format isn't YUV_420_888 or doesn't expose 3 planes.
98
+ *
99
+ * Performance: ~3 ms for 1920×1080 on a Galaxy A35. Dominated
100
+ * by the row-by-row copy through the direct ByteBuffers backing
101
+ * the camera planes.
102
+ *
103
+ * The Y plane is densely repacked (the source rowStride may be
104
+ * padded, but we discard padding on the way in so the result has
105
+ * `rowStride = width`). This is what callers want — `cv::Mat`
106
+ * wrap on the C++ side prefers tight strides, and downstream
107
+ * `YuvImage.compressToJpeg` requires densely-packed input.
108
+ */
109
+ fun packNV21(image: Image): PackedYuv? {
110
+ if (image.format != ImageFormat.YUV_420_888) return null
111
+ val planes = image.planes
112
+ if (planes.size < 3) return null
113
+
114
+ val w = image.width
115
+ val h = image.height
116
+ val ySize = w * h
117
+ val uvSize = w * h / 2
118
+ val nv21 = ByteArray(ySize + uvSize)
119
+
120
+ // ── Y plane (luminance) ─────────────────────────────────
121
+ val yPlane = planes[0]
122
+ val yBuf = yPlane.buffer
123
+ val yRowStride = yPlane.rowStride
124
+ if (yRowStride == w) {
125
+ // Source already densely packed — single block copy.
126
+ // Use duplicate() so we don't mutate the original buffer's
127
+ // position state (defensive — ARCore may have other readers
128
+ // of the same underlying buffer, though in practice it
129
+ // shouldn't).
130
+ yBuf.duplicate().apply { rewind() }.get(nv21, 0, ySize)
131
+ } else {
132
+ // Row-by-row copy when stride > width (padded rows).
133
+ val dup = yBuf.duplicate()
134
+ var dstOffset = 0
135
+ var srcOffset = 0
136
+ for (row in 0 until h) {
137
+ dup.position(srcOffset)
138
+ dup.get(nv21, dstOffset, w)
139
+ dstOffset += w
140
+ srcOffset += yRowStride
141
+ }
142
+ }
143
+
144
+ // ── U + V planes (chroma) ───────────────────────────────
145
+ // YUV_420_888 has them subsampled 2:1 so each plane physically
146
+ // covers (w/2) × (h/2). Pixel stride is 1 (planar) or 2
147
+ // (semi-planar interleaved). NV21 expects interleaved V-U.
148
+ val uPlane = planes[1]
149
+ val vPlane = planes[2]
150
+ val uBuf = uPlane.buffer
151
+ val vBuf = vPlane.buffer
152
+ val uRowStride = uPlane.rowStride
153
+ val uPixelStride = uPlane.pixelStride
154
+ val vRowStride = vPlane.rowStride
155
+ val vPixelStride = vPlane.pixelStride
156
+
157
+ // Fast path — most Android camera2 / ARCore producers emit
158
+ // semi-planar interleaved data with pixelStride=2. In that
159
+ // case the V plane's underlying bytes physically interleave
160
+ // V-U-V-U... and copying the V plane's full byte range
161
+ // produces NV21 layout directly.
162
+ if (uPixelStride == 2 && vPixelStride == 2 &&
163
+ uRowStride == vRowStride && uRowStride == w) {
164
+ val vBytes = vBuf.remaining().coerceAtMost(uvSize)
165
+ // Defensive duplicate() again — same reasoning as Y plane.
166
+ vBuf.duplicate().apply { rewind() }.get(nv21, ySize, vBytes)
167
+ return PackedYuv(nv21, w, h)
168
+ }
169
+
170
+ // Slow path — manual interleave for planar (pixelStride=1) or
171
+ // non-tight semi-planar layouts.
172
+ var pos = ySize
173
+ val rowsUv = h / 2
174
+ val colsUv = w / 2
175
+ for (row in 0 until rowsUv) {
176
+ for (col in 0 until colsUv) {
177
+ val vIdx = row * vRowStride + col * vPixelStride
178
+ val uIdx = row * uRowStride + col * uPixelStride
179
+ nv21[pos++] = vBuf.get(vIdx)
180
+ nv21[pos++] = uBuf.get(uIdx)
181
+ }
182
+ }
183
+ return PackedYuv(nv21, w, h)
184
+ }
185
+
186
+
187
+ /**
188
+ * Encode an already-packed NV21 buffer to a JPEG file on disk.
189
+ *
190
+ * Returns the output path on success, or null on any encode/write
191
+ * error (caller decides whether to log + drop the frame).
192
+ *
193
+ * `displayRotation` writes the appropriate EXIF orientation tag
194
+ * so consumers that respect EXIF (RN's Image loader, etc.)
195
+ * display the JPEG upright regardless of how the device was held
196
+ * at capture. Should be the value from
197
+ * `WindowManager.defaultDisplay.rotation` at capture time
198
+ * (Surface.ROTATION_0 / _90 / _180 / _270).
199
+ *
200
+ * Sensor orientation 90° assumed (back camera) — verified on
201
+ * Galaxy A35. Wire `CameraCharacteristics.SENSOR_ORIENTATION`
202
+ * through in a follow-up if we hit a device that differs.
203
+ */
204
+ fun encodeJpegFromNV21(
205
+ packed: PackedYuv,
59
206
  outputPath: String,
60
207
  jpegQuality: Int = 70,
61
208
  displayRotation: Int = Surface.ROTATION_0,
62
209
  ): String? {
63
- if (image.format != ImageFormat.YUV_420_888) return null
64
- val nv21 = yuv420toNV21(image) ?: return null
65
210
  val yuvImage = YuvImage(
66
- nv21,
211
+ packed.nv21,
67
212
  ImageFormat.NV21,
68
- image.width,
69
- image.height,
213
+ packed.width,
214
+ packed.height,
70
215
  null,
71
216
  )
72
217
  val baos = ByteArrayOutputStream()
73
218
  val ok = yuvImage.compressToJpeg(
74
- Rect(0, 0, image.width, image.height),
219
+ Rect(0, 0, packed.width, packed.height),
75
220
  jpegQuality.coerceIn(1, 100),
76
221
  baos,
77
222
  )
@@ -81,8 +226,9 @@ internal object YuvImageConverter {
81
226
  } catch (e: Throwable) {
82
227
  return null
83
228
  }
229
+
84
230
  // Write EXIF orientation tag based on display rotation.
85
- // Sensor orientation 90° assumed (back camera). The math:
231
+ // The math:
86
232
  // ROTATION_0 (portrait, sensor 90° CW from screen-up)
87
233
  // → JPEG needs 90° CW to display upright → ROTATE_90 (6)
88
234
  // ROTATION_90 (landscape-left, sensor aligned with screen)
@@ -93,11 +239,11 @@ internal object YuvImageConverter {
93
239
  // → 180° → ROTATE_180 (3)
94
240
  //
95
241
  // EXIF tag set EVEN when the orientation is normal — keeps
96
- // every output JPEG self-describing for downstream
97
- // consumers (cv::Stitcher does NOT auto-honour EXIF, see
98
- // BatchStitcher.applyExifOrientation; this metadata
99
- // exists primarily for the live thumbnail strip + future
100
- // RN Image renderers).
242
+ // every output JPEG self-describing for downstream consumers.
243
+ // cv::Stitcher does NOT auto-honour EXIF (see
244
+ // BatchStitcher.applyExifOrientation); this metadata exists
245
+ // primarily for the live thumbnail strip + future RN Image
246
+ // renderers.
101
247
  val exifOrientation = when (displayRotation) {
102
248
  Surface.ROTATION_0 -> ExifInterface.ORIENTATION_ROTATE_90
103
249
  Surface.ROTATION_90 -> ExifInterface.ORIENTATION_NORMAL
@@ -114,88 +260,35 @@ internal object YuvImageConverter {
114
260
  exif.saveAttributes()
115
261
  } catch (e: Throwable) {
116
262
  // EXIF write failed — JPEG itself is still valid; just
117
- // missing the orientation hint. Caller doesn't need to
118
- // know non-fatal.
263
+ // missing the orientation hint. Non-fatal; caller doesn't
264
+ // need to know.
119
265
  }
120
266
  return outputPath
121
267
  }
122
268
 
269
+
123
270
  /**
124
- * Pack a YUV_420_888 `Image` into a contiguous NV21 byte array.
271
+ * Single-call convenience wrapper: pack the `Image` and encode
272
+ * to JPEG in one step. Keeps the `Image` open through the entire
273
+ * ~25 ms encode — fine for one-shot photo capture, NOT
274
+ * recommended for the ~60 Hz `forwardToIncremental` path. See the
275
+ * file-level docs for the two-step alternative.
125
276
  *
126
- * The Image API exposes Y, U, V as three planes, each with its
127
- * own row stride and pixel stride. NV21 expects a single
128
- * contiguous buffer with Y plane first, then interleaved VU bytes
129
- * after. The repacking handles row + pixel strides that don't
130
- * match the dense layout.
277
+ * Caller still owns the `Image` and MUST close it afterwards;
278
+ * this function does not.
131
279
  */
132
- private fun yuv420toNV21(image: Image): ByteArray? {
133
- val w = image.width
134
- val h = image.height
135
- val ySize = w * h
136
- val uvSize = w * h / 2
137
-
138
- val nv21 = ByteArray(ySize + uvSize)
139
- val planes = image.planes
140
- if (planes.size < 3) return null
141
-
142
- // Y plane.
143
- val yPlane = planes[0]
144
- val yBuf = yPlane.buffer
145
- val yRowStride = yPlane.rowStride
146
- if (yRowStride == w) {
147
- yBuf.get(nv21, 0, ySize)
148
- } else {
149
- // Row-by-row copy when stride != width.
150
- var dstOffset = 0
151
- var srcOffset = 0
152
- for (row in 0 until h) {
153
- yBuf.position(srcOffset)
154
- yBuf.get(nv21, dstOffset, w)
155
- dstOffset += w
156
- srcOffset += yRowStride
157
- }
158
- }
159
-
160
- // U + V planes. YUV_420_888 has them subsampled 2:1 so each
161
- // covers (w/2) × (h/2). Pixel stride is 1 (planar) or 2
162
- // (semi-planar interleaved). NV21 requires interleaved VU.
163
- val uPlane = planes[1]
164
- val vPlane = planes[2]
165
- val uBuf = uPlane.buffer
166
- val vBuf = vPlane.buffer
167
- val uRowStride = uPlane.rowStride
168
- val uPixelStride = uPlane.pixelStride
169
- val vRowStride = vPlane.rowStride
170
- val vPixelStride = vPlane.pixelStride
171
-
172
- // Most camera2 / ARCore implementations on Android already
173
- // produce semi-planar interleaved data with pixelStride=2.
174
- // In that case Y plane + V plane (offset by 1) form NV21
175
- // directly with a single block copy. Detect + fast-path it.
176
- if (uPixelStride == 2 && vPixelStride == 2 &&
177
- uRowStride == vRowStride && uRowStride == w) {
178
- // The V plane in NV21 layout starts at vBuf's first byte.
179
- // Copy the entire V plane (which physically interleaves
180
- // with U bytes since pixelStride=2 means consecutive
181
- // bytes are V-U-V-U...).
182
- val vBytes = vBuf.remaining().coerceAtMost(uvSize)
183
- vBuf.get(nv21, ySize, vBytes)
184
- return nv21
185
- }
186
-
187
- // Slow path — manual interleave.
188
- var pos = ySize
189
- val rowsUv = h / 2
190
- val colsUv = w / 2
191
- for (row in 0 until rowsUv) {
192
- for (col in 0 until colsUv) {
193
- val vIdx = row * vRowStride + col * vPixelStride
194
- val uIdx = row * uRowStride + col * uPixelStride
195
- nv21[pos++] = vBuf.get(vIdx)
196
- nv21[pos++] = uBuf.get(uIdx)
197
- }
198
- }
199
- return nv21
280
+ fun encodeToJpeg(
281
+ image: Image,
282
+ outputPath: String,
283
+ jpegQuality: Int = 70,
284
+ displayRotation: Int = Surface.ROTATION_0,
285
+ ): String? {
286
+ val packed = packNV21(image) ?: return null
287
+ return encodeJpegFromNV21(
288
+ packed,
289
+ outputPath,
290
+ jpegQuality = jpegQuality,
291
+ displayRotation = displayRotation,
292
+ )
200
293
  }
201
294
  }
@@ -40,6 +40,7 @@
40
40
  */
41
41
  import React from 'react';
42
42
  import { type StyleProp, type ViewStyle } from 'react-native';
43
+ import type { DrawableFrameProcessor, ReadonlyFrameProcessor } from 'react-native-vision-camera';
43
44
  export type CaptureSource = 'ar' | 'non-ar';
44
45
  export type CameraLens = '1x' | '0.5x';
45
46
  export type StitchMode = 'auto' | 'panorama' | 'scans';
@@ -88,7 +89,16 @@ export type CameraCaptureResult = {
88
89
  * Errors surfaced via `onError`. Classified codes so consumers can
89
90
  * branch on the kind of failure (toast vs retry vs report).
90
91
  */
91
- export type CameraErrorCode = 'CAMERA_PERMISSION_DENIED' | 'CAMERA_DEVICE_UNAVAILABLE' | 'PHOTO_CAPTURE_FAILED' | 'PANORAMA_START_FAILED' | 'PANORAMA_FINALIZE_FAILED' | 'STITCH_NEED_MORE_IMGS' | 'STITCH_HOMOGRAPHY_FAIL' | 'STITCH_CAMERA_PARAMS_FAIL' | 'STITCH_OOM' | 'OUTPUT_WRITE_FAILED' | 'UNKNOWN';
92
+ export type CameraErrorCode = 'CAMERA_PERMISSION_DENIED' | 'CAMERA_DEVICE_UNAVAILABLE' | 'PHOTO_CAPTURE_FAILED' | 'PANORAMA_START_FAILED' | 'PANORAMA_FINALIZE_FAILED' | 'STITCH_NEED_MORE_IMGS' | 'STITCH_HOMOGRAPHY_FAIL' | 'STITCH_CAMERA_PARAMS_FAIL' | 'STITCH_OOM' | 'OUTPUT_WRITE_FAILED'
93
+ /**
94
+ * Vision-camera surfaced a runtime error that isn't a known
95
+ * transient lifecycle event (those are swallowed inside the SDK's
96
+ * `<CameraView>`). Examples that DO reach the host as this code:
97
+ * `format/invalid-format`, `capture/recording-canceled`,
98
+ * `device/microphone-permission-denied`, ... The full error
99
+ * object is on `.cause` for inspection.
100
+ */
101
+ | 'VISION_CAMERA_RUNTIME' | 'UNKNOWN';
92
102
  export declare class CameraError extends Error {
93
103
  readonly code: CameraErrorCode;
94
104
  readonly cause?: unknown;
@@ -163,6 +173,45 @@ export interface CameraProps {
163
173
  onLensChange?: (lens: CameraLens) => void;
164
174
  onFramesDropped?: (info: FramesDroppedInfo) => void;
165
175
  onError?: (err: CameraError) => void;
176
+ /**
177
+ * Optional vision-camera frame processor. Only attached to the
178
+ * non-AR preview (AR mode uses ARCameraView, which doesn't expose
179
+ * a worklet seam). Build the worklet on the host side with
180
+ * `useFrameProcessor` from `react-native-vision-camera`.
181
+ *
182
+ * Introduced for F8 (FrameProcessor port) — see
183
+ * `docs/f8-frame-processor-plan.md`.
184
+ *
185
+ * As of v0.5 (F8.3) this prop is **deprecated for the standard
186
+ * non-AR capture flow**: the SDK now installs its own frame
187
+ * processor via `useFrameProcessorDriver` that pipes pixel
188
+ * buffers into the incremental stitcher with synthesised pose.
189
+ * Setting this prop in the default mode will be IGNORED with a
190
+ * one-time console.warn — supplying your own worklet would race
191
+ * with the SDK's pixel-buffer feed.
192
+ *
193
+ * Three coexistence rules:
194
+ * * Default (modern non-AR): SDK owns the worklet, this prop
195
+ * is ignored.
196
+ * * `legacyDriver={true}`: SDK uses the old `useIncrementalJSDriver`
197
+ * (takeSnapshot path). Honoured for diagnostics or as an
198
+ * escape hatch.
199
+ * * AR mode: vision-camera Camera isn't mounted, this prop is
200
+ * irrelevant.
201
+ */
202
+ frameProcessor?: ReadonlyFrameProcessor | DrawableFrameProcessor;
203
+ /**
204
+ * Opt back into the legacy `useIncrementalJSDriver` for non-AR
205
+ * captures (the v0.4 path: `takeSnapshot` → JPEG → cache file →
206
+ * `IncrementalStitcher.processFrameAtPath`).
207
+ *
208
+ * Default `false` (use the new `useFrameProcessorDriver`, which
209
+ * runs the gate on the camera producer thread at native frame
210
+ * rate via a vision-camera Frame Processor plugin). The legacy
211
+ * path will be removed in v0.6 — set this only if you hit a
212
+ * specific issue with the new driver and need to ship a fix.
213
+ */
214
+ legacyDriver?: boolean;
166
215
  }
167
216
  /**
168
217
  * The public `<Camera>` component.
@@ -98,6 +98,7 @@ const useCapture_1 = require("./useCapture");
98
98
  const useDeviceOrientation_1 = require("./useDeviceOrientation");
99
99
  const incremental_1 = require("../stitching/incremental");
100
100
  const useIncrementalJSDriver_1 = require("../stitching/useIncrementalJSDriver");
101
+ const useFrameProcessorDriver_1 = require("../stitching/useFrameProcessorDriver");
101
102
  const useIncrementalStitcher_1 = require("../stitching/useIncrementalStitcher");
102
103
  const useIMUTranslationGate_1 = require("../sensors/useIMUTranslationGate");
103
104
  const paths_1 = require("../utils/paths");
@@ -270,7 +271,7 @@ function extractPanoramaOverrides(props) {
270
271
  * The public `<Camera>` component.
271
272
  */
272
273
  function Camera(props) {
273
- const { defaultCaptureSource = 'ar', defaultLens = '1x', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, outputDir, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, } = 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
275
  const insets = (0, react_native_safe_area_context_1.useSafeAreaInsets)();
275
276
  // ── State ───────────────────────────────────────────────────────
276
277
  const [arPreference, setArPreference] = (0, react_1.useState)(defaultCaptureSource === 'ar');
@@ -434,10 +435,40 @@ function Camera(props) {
434
435
  // imperative pattern (start on hold-start, stop on hold-end) avoids
435
436
  // the re-render churn entirely.
436
437
  const jsDriver = (0, useIncrementalJSDriver_1.useIncrementalJSDriver)();
437
- // Safety: ensure the driver is stopped if the component unmounts
438
+ // F8.3 vision-camera Frame Processor variant. Always
439
+ // instantiated so we don't have conditional hook calls; only one
440
+ // of the two drivers actually .start()s per capture. Stop() on
441
+ // an idle driver is a no-op.
442
+ const fpDriver = (0, useFrameProcessorDriver_1.useFrameProcessorDriver)();
443
+ // Safety: ensure both drivers are stopped if the component unmounts
438
444
  // mid-recording. Empty deps so this only fires on unmount.
439
445
  // eslint-disable-next-line react-hooks/exhaustive-deps
440
- (0, react_1.useEffect)(() => () => { jsDriver.stop(); }, []);
446
+ (0, react_1.useEffect)(() => () => { jsDriver.stop(); fpDriver.stop(); }, []);
447
+ // F8.3 — one-shot deprecation warning when the host supplies their
448
+ // own `frameProcessor` while running in the default (Frame
449
+ // Processor driver) mode. Two worklets racing on the same
450
+ // producer thread would corrupt the engine's workQueue ordering;
451
+ // the SDK's own worklet wins and the host's is ignored. Hosts
452
+ // that *need* a custom worklet must opt into `legacyDriver={true}`
453
+ // (which switches off the SDK's worklet entirely).
454
+ const hostFrameProcessorIgnoredWarnedRef = (0, react_1.useRef)(false);
455
+ if (hostFrameProcessor != null
456
+ && !legacyDriver
457
+ && !hostFrameProcessorIgnoredWarnedRef.current) {
458
+ hostFrameProcessorIgnoredWarnedRef.current = true;
459
+ // eslint-disable-next-line no-console
460
+ console.warn('[react-native-image-stitcher] The `frameProcessor` prop on '
461
+ + '<Camera> is ignored when the default driver is active '
462
+ + '(legacyDriver=false). Either remove the prop or set '
463
+ + 'legacyDriver={true} to opt into the legacy path.');
464
+ }
465
+ // The Frame Processor worklet actually bound to vision-camera's
466
+ // Camera. Resolution order:
467
+ // 1. Legacy mode: honor the host's prop (or null).
468
+ // 2. Modern mode: SDK driver's worklet, regardless of host's prop.
469
+ const effectiveFrameProcessor = legacyDriver
470
+ ? (hostFrameProcessor ?? null)
471
+ : fpDriver.frameProcessor;
441
472
  // ── Subscribe to engine state for live keyframe thumbs ──────────
442
473
  (0, react_1.useEffect)(() => {
443
474
  const sub = (0, incremental_1.subscribeIncrementalState)((state) => {
@@ -493,6 +524,17 @@ function Camera(props) {
493
524
  const accepted = incrementalState?.acceptedCount ?? 0;
494
525
  if (accepted > lastAcceptedCountRef.current) {
495
526
  lastAcceptedCountRef.current = accepted;
527
+ // F8.3 review-of-review (M3 revert): originally gated this to
528
+ // `legacyDriver` because the Frame Processor driver doesn't
529
+ // consult `imuGate` for its own pose synthesis. That ignored a
530
+ // load-bearing side effect: `imuGate.resetAnchor()` bounds the
531
+ // IIR-integrator drift window per-accept, and
532
+ // `imuGate.getTotalAbsMetres()` is read at finalize time
533
+ // (Camera.tsx:1097) as `imuTranslationMetres` into the native
534
+ // stitchMode auto-resolver (PANORAMA vs SCANS). Without the
535
+ // per-accept reset, long FP-driver captures let IIR drift
536
+ // compound → inflated metres → biased toward SCANS. Keep the
537
+ // reset firing for ALL non-AR modes.
496
538
  if (isNonAR) {
497
539
  imuGate.resetAnchor();
498
540
  }
@@ -609,7 +651,13 @@ function Camera(props) {
609
651
  snapshotEveryNAccepts: 1,
610
652
  frameRotationDegrees: orientationRotation,
611
653
  captureOrientation: deviceOrientation,
612
- frameSourceMode: isNonAR ? 'jsDriver' : 'arSession',
654
+ // F8.3 non-AR captures pick between the new Frame Processor
655
+ // driver (default) and the legacy JS-snapshot driver (opt-in
656
+ // via `legacyDriver={true}`). AR captures always use the
657
+ // ARSession-driven path.
658
+ frameSourceMode: isNonAR
659
+ ? (legacyDriver ? 'jsDriver' : 'frameProcessor')
660
+ : 'arSession',
613
661
  composeWidth: 1920,
614
662
  composeHeight: 1080,
615
663
  canvasWidth: 5000,
@@ -620,15 +668,27 @@ function Camera(props) {
620
668
  captureSource: effectiveCaptureSource,
621
669
  }),
622
670
  });
671
+ // F8.3 review-of-review (M3 revert): `imuGate.resetAnchor()`
672
+ // is load-bearing for the stitchMode auto-resolver (see the
673
+ // matching comment on the per-accept reset useEffect above).
674
+ // Keep firing it on every capture start, not just legacy mode.
623
675
  imuGate.resetAnchor();
624
- // Start pumping vision-camera snapshots into the engine for
625
- // non-AR captures. AR mode feeds frames natively from the
626
- // ARSession, so the JS driver stays idle in that path. This
627
- // mirrors AuditCaptureScreen.handleHoldStart's `androidDriver.start`
628
- // imperative call see the comment near `useIncrementalJSDriver`
629
- // for why this is NOT done via useEffect.
676
+ // Start the non-AR frame source. AR mode feeds natively from
677
+ // ARSession so both drivers stay idle in that path.
678
+ // * Default: Frame Processor driver worklet runs on the
679
+ // producer thread, plugin calls `consumeFrameFromPlugin`
680
+ // directly. No camera ref needed (vision-camera owns it).
681
+ // * Legacy: JS driver `takeSnapshot` + `processFrameAtPath`
682
+ // via the cameraRef.
683
+ // Imperative-pattern rationale: see the useIncrementalJSDriver
684
+ // comment above re. why this isn't a useEffect.
630
685
  if (isNonAR) {
631
- jsDriver.start(visionCameraRef);
686
+ if (legacyDriver) {
687
+ jsDriver.start(visionCameraRef);
688
+ }
689
+ else {
690
+ fpDriver.start();
691
+ }
632
692
  }
633
693
  }
634
694
  catch (err) {
@@ -644,16 +704,21 @@ function Camera(props) {
644
704
  effectiveCaptureSource,
645
705
  imuGate,
646
706
  jsDriver,
707
+ fpDriver,
708
+ legacyDriver,
647
709
  onError,
648
710
  ]);
649
711
  const handleHoldEnd = (0, react_1.useCallback)(async () => {
650
712
  if (statusPhase !== 'recording')
651
713
  return;
652
714
  setStatusPhase('stitching');
653
- // Stop pumping new snapshots before finalizing so the engine isn't
654
- // racing the final cv::Stitcher pass against late-arriving keyframes.
655
- // No-op in AR mode where jsDriver was never started.
715
+ // Stop pumping new frames before finalizing so the engine isn't
716
+ // racing the final cv::Stitcher pass against late-arriving
717
+ // keyframes. Both stop() calls are no-ops when the
718
+ // corresponding driver wasn't started (AR mode, or the inactive
719
+ // driver in non-AR mode).
656
720
  jsDriver.stop();
721
+ fpDriver.stop();
657
722
  try {
658
723
  // Compose the panorama output path: host-controlled if
659
724
  // `outputDir` is set, else the lib's canonical capture dir
@@ -723,6 +788,7 @@ function Camera(props) {
723
788
  onError,
724
789
  recordingStartedAt,
725
790
  jsDriver,
791
+ fpDriver,
726
792
  // F10 Phase 2 review N1 — these four were missing pre-fix. The
727
793
  // callback reads `settings.debug` (to gate the stitchToast),
728
794
  // `isNonAR` (to decide whether to read IMU totalAbs translation),
@@ -755,7 +821,26 @@ function Camera(props) {
755
821
  // the very first buffered preview frame. Android takeSnapshot
756
822
  // works either way. Pattern matches AuditCaptureScreen.tsx
757
823
  // which has run on `video` (true) for months without issue.
758
- video: true, flash: "off", style: react_native_1.StyleSheet.absoluteFill })),
824
+ video: true, flash: "off", style: react_native_1.StyleSheet.absoluteFill,
825
+ // F8 (FrameProcessor port) — host-supplied worklet runs on
826
+ // the camera producer thread for every frame. Only wired
827
+ // in non-AR mode; AR mode uses ARCameraView which doesn't
828
+ // expose a frame-processor seam. See
829
+ // docs/f8-frame-processor-plan.md.
830
+ cameraProps: effectiveFrameProcessor != null
831
+ ? { frameProcessor: effectiveFrameProcessor }
832
+ : undefined, onError: (err) => {
833
+ // CameraView already filters known transient lifecycle
834
+ // errors (screen-lock, etc.) before invoking this. What
835
+ // reaches here is a real vision-camera runtime issue:
836
+ // pull `code`/`message` defensively (the type is
837
+ // `unknown` from CameraView's perspective) and wrap in
838
+ // a SDK-typed `CameraError` so hosts get a stable shape.
839
+ const e = err;
840
+ const codeStr = e?.code ?? 'unknown';
841
+ const msg = e?.message ?? String(err);
842
+ onError?.(new CameraError('VISION_CAMERA_RUNTIME', `${codeStr}: ${msg}`, err));
843
+ } })),
759
844
  react_1.default.createElement(CaptureStatusOverlay_1.CaptureStatusOverlay, { phase: statusPhase, topInset: insets.top, recordingStartedAt: recordingStartedAt ?? undefined }),
760
845
  settings.debug && (react_1.default.createElement(react_1.default.Fragment, null,
761
846
  react_1.default.createElement(CaptureOrientationPill_1.CaptureOrientationPill, { orientation: deviceOrientation, topInset: insets.top }),