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.
- package/CHANGELOG.md +108 -0
- package/README.md +1 -0
- package/android/build.gradle +33 -0
- package/android/src/main/java/io/imagestitcher/rn/CvFlowGateFrameProcessor.kt +163 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +214 -0
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +65 -7
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +137 -124
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +212 -119
- package/dist/camera/Camera.d.ts +50 -1
- package/dist/camera/Camera.js +100 -15
- package/dist/camera/CameraView.d.ts +17 -5
- package/dist/camera/CameraView.js +28 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -1
- package/dist/stitching/incremental.d.ts +13 -4
- package/dist/stitching/useFrameProcessorDriver.d.ts +148 -0
- package/dist/stitching/useFrameProcessorDriver.js +321 -0
- package/dist/stitching/useIncrementalJSDriver.js +21 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +128 -8
- package/ios/Sources/RNImageStitcher/KeyframeGateFrameProcessor.mm +196 -0
- package/package.json +3 -1
- package/src/camera/Camera.tsx +164 -14
- package/src/camera/CameraView.tsx +50 -0
- package/src/index.ts +12 -0
- package/src/stitching/incremental.ts +12 -3
- package/src/stitching/useFrameProcessorDriver.ts +407 -0
- package/src/stitching/useIncrementalJSDriver.ts +24 -0
|
@@ -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:
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
//
|
|
98
|
-
// BatchStitcher.applyExifOrientation; this metadata
|
|
99
|
-
//
|
|
100
|
-
//
|
|
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.
|
|
118
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
127
|
-
*
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
val
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
}
|
package/dist/camera/Camera.d.ts
CHANGED
|
@@ -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'
|
|
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.
|
package/dist/camera/Camera.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
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
|
-
|
|
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
|
|
654
|
-
// racing the final cv::Stitcher pass against late-arriving
|
|
655
|
-
//
|
|
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 }),
|