react-native-image-stitcher 0.14.2 → 0.15.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 +164 -0
- package/README.md +35 -0
- package/RNImageStitcher.podspec +8 -7
- package/android/build.gradle +0 -16
- package/android/src/main/cpp/CMakeLists.txt +2 -63
- package/android/src/main/cpp/image_stitcher_jni.cpp +14 -0
- package/android/src/main/cpp/keyframe_gate_jni.cpp +13 -0
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +285 -3
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +180 -1162
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +29 -0
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +0 -4
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +129 -71
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +49 -0
- package/cpp/keyframe_gate.cpp +82 -23
- package/cpp/keyframe_gate.hpp +31 -2
- package/cpp/stitcher.cpp +208 -28
- package/cpp/tests/CMakeLists.txt +18 -12
- package/cpp/tests/keyframe_timebudget_test.cpp +65 -0
- package/cpp/tests/warp_guard_test.cpp +48 -0
- package/cpp/warp_guard.hpp +41 -0
- package/dist/camera/Camera.d.ts +31 -16
- package/dist/camera/Camera.js +11 -3
- package/dist/camera/CameraView.js +93 -3
- package/dist/camera/CaptureStitchStatsToast.d.ts +15 -2
- package/dist/camera/CaptureStitchStatsToast.js +27 -7
- package/dist/camera/PanoramaSettings.d.ts +10 -223
- package/dist/camera/PanoramaSettings.js +6 -28
- package/dist/camera/PanoramaSettingsBridge.d.ts +1 -24
- package/dist/camera/PanoramaSettingsBridge.js +3 -102
- package/dist/camera/PanoramaSettingsModal.js +7 -1
- package/dist/camera/buildPanoramaInitialSettings.d.ts +11 -0
- package/dist/camera/buildPanoramaInitialSettings.js +4 -0
- package/dist/camera/cameraErrorMessages.d.ts +32 -0
- package/dist/camera/cameraErrorMessages.js +53 -0
- package/dist/camera/selectCaptureDevice.d.ts +5 -1
- package/dist/camera/selectCaptureDevice.js +22 -2
- package/dist/camera/useCapture.js +38 -0
- package/dist/index.d.ts +5 -8
- package/dist/index.js +11 -34
- package/dist/stitching/incremental.d.ts +1 -117
- package/dist/stitching/stitchVideo.d.ts +0 -35
- package/dist/types.d.ts +0 -87
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +96 -674
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +9 -12
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +14 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +7 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +6 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +2 -2
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +3 -3
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +28 -60
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +180 -921
- package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +82 -7
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +10 -35
- package/ios/Sources/RNImageStitcher/Stitcher.swift +84 -35
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +13 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +132 -5
- package/package.json +3 -2
- package/src/camera/Camera.tsx +44 -23
- package/src/camera/CameraView.tsx +113 -4
- package/src/camera/CaptureStitchStatsToast.tsx +58 -14
- package/src/camera/PanoramaSettings.ts +16 -289
- package/src/camera/PanoramaSettingsBridge.ts +3 -114
- package/src/camera/PanoramaSettingsModal.tsx +14 -1
- package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +3 -188
- package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +41 -0
- package/src/camera/__tests__/cameraErrorMessages.test.ts +76 -0
- package/src/camera/__tests__/selectCaptureDevice.test.ts +33 -0
- package/src/camera/buildPanoramaInitialSettings.ts +17 -0
- package/src/camera/cameraErrorMessages.ts +84 -0
- package/src/camera/selectCaptureDevice.ts +28 -3
- package/src/camera/useCapture.ts +44 -1
- package/src/index.ts +11 -40
- package/src/stitching/incremental.ts +3 -140
- package/src/stitching/stitchVideo.ts +0 -26
- package/src/types.ts +0 -95
- package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +0 -227
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +0 -1081
- package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +0 -103
- package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +0 -256
- package/cpp/stitcher_frame_jsi.cpp +0 -214
- package/cpp/stitcher_frame_jsi.hpp +0 -108
- package/cpp/stitcher_proxy_jsi.cpp +0 -109
- package/cpp/stitcher_proxy_jsi.hpp +0 -46
- package/cpp/stitcher_worklet_dispatch.cpp +0 -103
- package/cpp/stitcher_worklet_dispatch.hpp +0 -71
- package/cpp/stitcher_worklet_registry.cpp +0 -91
- package/cpp/stitcher_worklet_registry.hpp +0 -146
- package/cpp/tests/stitcher_worklet_registry_test.cpp +0 -195
- package/dist/stitching/IncrementalStitcherView.d.ts +0 -41
- package/dist/stitching/IncrementalStitcherView.js +0 -157
- package/dist/stitching/StitcherWorkletRegistry.d.ts +0 -117
- package/dist/stitching/StitcherWorkletRegistry.js +0 -78
- package/dist/stitching/ensureStitcherProxyInstalled.d.ts +0 -8
- package/dist/stitching/ensureStitcherProxyInstalled.js +0 -81
- package/dist/stitching/useFrameProcessor.d.ts +0 -119
- package/dist/stitching/useFrameProcessor.js +0 -196
- package/dist/stitching/useFrameStream.d.ts +0 -34
- package/dist/stitching/useFrameStream.js +0 -234
- package/dist/stitching/useThrottledFrameProcessor.d.ts +0 -33
- package/dist/stitching/useThrottledFrameProcessor.js +0 -132
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +0 -474
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +0 -1328
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +0 -103
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +0 -3285
- package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +0 -128
- package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +0 -313
- package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +0 -185
- package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +0 -60
- package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +0 -214
- package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +0 -42
- package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +0 -160
- package/src/stitching/IncrementalStitcherView.tsx +0 -198
- package/src/stitching/StitcherWorkletRegistry.ts +0 -156
- package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +0 -176
- package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +0 -94
- package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +0 -178
- package/src/stitching/ensureStitcherProxyInstalled.ts +0 -141
- package/src/stitching/useFrameProcessor.ts +0 -226
- package/src/stitching/useFrameStream.ts +0 -271
- package/src/stitching/useThrottledFrameProcessor.ts +0 -145
|
@@ -140,6 +140,23 @@ internal class KeyframeGate : AutoCloseable {
|
|
|
140
140
|
nativeSetFlowMaxTranslationM(nativeHandle, value)
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/// Wall-clock keyframe-interval budget, in MILLISECONDS, between
|
|
144
|
+
/// consecutive accepted keyframes before force-acceptance. Same
|
|
145
|
+
/// knob iOS exposes via setMaxKeyframeIntervalMs (KeyframeGate.swift
|
|
146
|
+
/// `maxKeyframeIntervalMs`). Unlike flowMaxTranslationM this applies
|
|
147
|
+
/// to BOTH the Pose and Flow strategies, and is passed STRAIGHT
|
|
148
|
+
/// THROUGH (already in the unit the C++ expects — no conversion).
|
|
149
|
+
/// Default 2000 ms (matches iOS); 0 = disabled. The C++ setter
|
|
150
|
+
/// clamps to ≥ 0. NOTE: like every other facade property the
|
|
151
|
+
/// initializer below does NOT fire this setter, so the caller
|
|
152
|
+
/// (IncrementalStitcher.kt) writes it explicitly at capture start
|
|
153
|
+
/// to push the value into C++ (same contract as the iOS facade).
|
|
154
|
+
var maxKeyframeIntervalMs: Double = 2000.0
|
|
155
|
+
set(value) {
|
|
156
|
+
field = value
|
|
157
|
+
nativeSetMaxKeyframeIntervalMs(nativeHandle, value)
|
|
158
|
+
}
|
|
159
|
+
|
|
143
160
|
/// 2026-05-22 (audit F5) — Flow strategy: Shi-Tomasi max corners
|
|
144
161
|
/// to track per frame. Same knob iOS exposes via setFlowMaxCorners.
|
|
145
162
|
/// C++ clamps to ≥ 30. Higher = more sensitive to fine detail but
|
|
@@ -305,6 +322,9 @@ internal class KeyframeGate : AutoCloseable {
|
|
|
305
322
|
private external fun nativeSetDisableAngularFallback(handle: Long, disabled: Boolean)
|
|
306
323
|
private external fun nativeSetFlowNoveltyPercentile(handle: Long, percentile: Double)
|
|
307
324
|
private external fun nativeSetFlowMaxTranslationM(handle: Long, metres: Double)
|
|
325
|
+
// Wall-clock keyframe-interval budget (ms). iOS parity:
|
|
326
|
+
// KeyframeGateBridge.setMaxKeyframeIntervalMs.
|
|
327
|
+
private external fun nativeSetMaxKeyframeIntervalMs(handle: Long, ms: Double)
|
|
308
328
|
// 2026-05-22 (audit F5) — flow-strategy tunables that were
|
|
309
329
|
// previously iOS-only. Add Android JNI parity so the Settings UI
|
|
310
330
|
// sliders work on both platforms.
|
|
@@ -362,6 +382,15 @@ internal class KeyframeGate : AutoCloseable {
|
|
|
362
382
|
9 -> "max-reached"
|
|
363
383
|
10 -> "overlap-too-high"
|
|
364
384
|
11 -> "overlap-too-high (angular)"
|
|
385
|
+
// Flow-strategy reasons (v0.3.0, cpp KeyframeGateDecisionReason
|
|
386
|
+
// 12-15) — strings must match the cpp/iOS labels exactly.
|
|
387
|
+
12 -> "ok-flow"
|
|
388
|
+
13 -> "first-flow"
|
|
389
|
+
14 -> "overlap-too-high (flow)"
|
|
390
|
+
15 -> "ok-flow-translation"
|
|
391
|
+
// Wall-clock keyframe-interval force-accept (Pose + Flow);
|
|
392
|
+
// cpp KeyframeGateDecisionReason::AcceptTimeInterval = 16.
|
|
393
|
+
16 -> "ok-time-interval"
|
|
365
394
|
else -> "unknown($code)"
|
|
366
395
|
}
|
|
367
396
|
}
|
|
@@ -101,10 +101,6 @@ class RNImageStitcherPackage : ReactPackage {
|
|
|
101
101
|
RNSARSession(reactContext),
|
|
102
102
|
IncrementalStitcher(reactContext),
|
|
103
103
|
FileBridge(reactContext),
|
|
104
|
-
// v0.8.0 Phase 4b.ii — Android JSI installer for the
|
|
105
|
-
// host-worklet `__stitcherProxy` global. Mirror of
|
|
106
|
-
// iOS' `StitcherJsiInstaller`.
|
|
107
|
-
StitcherJsiInstallerModule(reactContext),
|
|
108
104
|
)
|
|
109
105
|
}
|
|
110
106
|
|
|
@@ -58,6 +58,13 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
58
58
|
defStyle: Int = 0,
|
|
59
59
|
) : FrameLayout(context, attrs, defStyle), GLSurfaceView.Renderer {
|
|
60
60
|
|
|
61
|
+
// Raw camera sensor aspect ratio (W÷H, always > 1 for landscape sensors).
|
|
62
|
+
// Initialised to 4:3 — a safe fallback for the first layout pass before
|
|
63
|
+
// the session is attached. Updated from session.cameraConfig once the
|
|
64
|
+
// session is available; many Android ARCore devices use 16:9 configs
|
|
65
|
+
// (e.g. Pixel phones), so reading it dynamically is important here.
|
|
66
|
+
private var cameraAspect: Float = 4f / 3f
|
|
67
|
+
|
|
61
68
|
private val glView: GLSurfaceView = GLSurfaceView(context).also { v ->
|
|
62
69
|
v.preserveEGLContextOnPause = true
|
|
63
70
|
v.setEGLContextClientVersion(2)
|
|
@@ -123,6 +130,10 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
123
130
|
}
|
|
124
131
|
|
|
125
132
|
init {
|
|
133
|
+
// Black background avoids a flash before the GL surface starts
|
|
134
|
+
// clearing itself black each frame (the GL-level letterbox draws
|
|
135
|
+
// the bars; this is just belt-and-suspenders for the first frame).
|
|
136
|
+
setBackgroundColor(android.graphics.Color.BLACK)
|
|
126
137
|
addView(
|
|
127
138
|
glView,
|
|
128
139
|
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
|
|
@@ -163,6 +174,26 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
163
174
|
} catch (e: CameraNotAvailableException) {
|
|
164
175
|
Log.w(TAG, "session.resume on attach: $e")
|
|
165
176
|
}
|
|
177
|
+
// Read the actual camera image dimensions from the ARCore
|
|
178
|
+
// session config so the GL-level letterbox can size its box.
|
|
179
|
+
// cameraConfig is stable after session creation; on Pixel and
|
|
180
|
+
// some other Android devices the default config is 16:9, not
|
|
181
|
+
// 4:3, so we must read dynamically rather than hard-code.
|
|
182
|
+
try {
|
|
183
|
+
val size = session.cameraConfig.imageSize
|
|
184
|
+
if (size.width > 0 && size.height > 0) {
|
|
185
|
+
cameraAspect = size.width.toFloat() / size.height.toFloat()
|
|
186
|
+
Log.i(TAG, "cameraConfig imageSize: ${size.width}×${size.height} → cameraAspect=$cameraAspect")
|
|
187
|
+
// Invalidate the cached display geometry so the next
|
|
188
|
+
// onDrawFrame re-pushes it with the now-known camera
|
|
189
|
+
// aspect. The GL-level letterbox recomputes the box
|
|
190
|
+
// every frame — no view resize needed.
|
|
191
|
+
lastGeomW = -1
|
|
192
|
+
lastGeomH = -1
|
|
193
|
+
}
|
|
194
|
+
} catch (t: Throwable) {
|
|
195
|
+
Log.w(TAG, "cameraConfig not yet available in onAttach; will use $cameraAspect fallback: ${t.message}")
|
|
196
|
+
}
|
|
166
197
|
} else {
|
|
167
198
|
Log.w(
|
|
168
199
|
TAG,
|
|
@@ -222,6 +253,54 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
222
253
|
ingestActive = active
|
|
223
254
|
}
|
|
224
255
|
|
|
256
|
+
// ── GL-level letterbox ─────────────────────────────────────────
|
|
257
|
+
//
|
|
258
|
+
// The [glView] stays full-screen (MATCH_PARENT); we letterbox at the
|
|
259
|
+
// GL layer instead of resizing the SurfaceView. Resizing the view
|
|
260
|
+
// does NOT work for ARCore: its BackgroundRenderer maps the camera
|
|
261
|
+
// texture with `Frame.transformCoordinates2d`, which uses the
|
|
262
|
+
// session's *display geometry* — not the view bounds. A resized view
|
|
263
|
+
// therefore still rendered the full-screen (centre-cropped) camera,
|
|
264
|
+
// merely clipped to the smaller view → a cropped scene with one
|
|
265
|
+
// visible bar (the other hidden behind the capture controls).
|
|
266
|
+
//
|
|
267
|
+
// The correct fix is pure GL + ARCore geometry, applied per frame:
|
|
268
|
+
// 1. clear the WHOLE surface to black → the letterbox bars,
|
|
269
|
+
// 2. setDisplayGeometry to the BOX size → ARCore's UV transform
|
|
270
|
+
// fills the box aspect; when box aspect == camera aspect there
|
|
271
|
+
// is nothing to crop, so the full FOV shows,
|
|
272
|
+
// 3. glViewport to the centred box → camera draws only there.
|
|
273
|
+
|
|
274
|
+
/** Last display geometry pushed to ARCore; only re-push on change. */
|
|
275
|
+
private var lastGeomW: Int = -1
|
|
276
|
+
private var lastGeomH: Int = -1
|
|
277
|
+
private var lastGeomRotation: Int = -1
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The centred letterbox rect [x, y, w, h] inside the full GL surface
|
|
281
|
+
* that preserves the camera's content aspect ratio. The sensor is
|
|
282
|
+
* landscape (e.g. 640×480, 4:3); in portrait the on-screen content
|
|
283
|
+
* aspect is the inverse, so [cameraAspect] is inverted when the
|
|
284
|
+
* surface is taller than wide. Falls back to the full surface until
|
|
285
|
+
* the surface has been measured.
|
|
286
|
+
*/
|
|
287
|
+
private fun letterboxBox(): IntArray {
|
|
288
|
+
val sw = surfaceWidth
|
|
289
|
+
val sh = surfaceHeight
|
|
290
|
+
if (sw <= 0 || sh <= 0 || cameraAspect <= 0f) return intArrayOf(0, 0, sw, sh)
|
|
291
|
+
val contentAspect = if (sh > sw) 1f / cameraAspect else cameraAspect
|
|
292
|
+
val surfaceAspect = sw.toFloat() / sh.toFloat()
|
|
293
|
+
return if (surfaceAspect > contentAspect) {
|
|
294
|
+
// Surface wider than content — vertical bars left/right.
|
|
295
|
+
val w = (sh * contentAspect).toInt()
|
|
296
|
+
intArrayOf((sw - w) / 2, 0, w, sh)
|
|
297
|
+
} else {
|
|
298
|
+
// Surface taller than content — horizontal bars top/bottom.
|
|
299
|
+
val h = (sw / contentAspect).toInt()
|
|
300
|
+
intArrayOf(0, (sh - h) / 2, sw, h)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
225
304
|
// ── GLSurfaceView.Renderer ─────────────────────────────────────
|
|
226
305
|
|
|
227
306
|
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
|
|
@@ -238,6 +317,10 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
238
317
|
}
|
|
239
318
|
|
|
240
319
|
override fun onDrawFrame(gl: GL10?) {
|
|
320
|
+
// Step 1 — paint the WHOLE surface black. This is the letterbox:
|
|
321
|
+
// anything outside the camera box below stays black.
|
|
322
|
+
GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight)
|
|
323
|
+
GLES20.glClearColor(0f, 0f, 0f, 1f)
|
|
241
324
|
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
|
|
242
325
|
|
|
243
326
|
val session = sessionRef.get() ?: run {
|
|
@@ -251,10 +334,14 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
251
334
|
if (!sessionTextureBound) {
|
|
252
335
|
backgroundRenderer.bindToSession(session)
|
|
253
336
|
sessionTextureBound = true
|
|
254
|
-
// Ensure ARCore knows the surface geometry.
|
|
255
|
-
applyDisplayGeometry()
|
|
256
337
|
}
|
|
257
338
|
|
|
339
|
+
// Step 2 — keep ARCore's display geometry equal to the letterbox
|
|
340
|
+
// box (not the full surface) so its UV transform fills the box
|
|
341
|
+
// aspect with the full camera FOV (no centre-crop). Cheap: only
|
|
342
|
+
// calls setDisplayGeometry when the box actually changes.
|
|
343
|
+
applyDisplayGeometry()
|
|
344
|
+
|
|
258
345
|
val frame = try {
|
|
259
346
|
session.update()
|
|
260
347
|
} catch (e: SessionPausedException) {
|
|
@@ -264,6 +351,11 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
264
351
|
return
|
|
265
352
|
}
|
|
266
353
|
|
|
354
|
+
// Step 3 — confine the camera draw to the centred box; the black
|
|
355
|
+
// cleared in step 1 remains as the bars around it.
|
|
356
|
+
val box = letterboxBox()
|
|
357
|
+
GLES20.glViewport(box[0], box[1], box[2], box[3])
|
|
358
|
+
|
|
267
359
|
// Draw the camera background regardless of tracking state —
|
|
268
360
|
// gives the user something to look at while AR initialises.
|
|
269
361
|
backgroundRenderer.draw(frame)
|
|
@@ -296,20 +388,12 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
296
388
|
// contract was already in place for Phase 4.
|
|
297
389
|
appendPose(camera, frame.timestamp)
|
|
298
390
|
|
|
299
|
-
// Forward to the incremental stitcher
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
//
|
|
306
|
-
// `hasHostWorklets()` is a microsecond atomic-read on the
|
|
307
|
-
// native registry — cheap enough to hit per frame. When
|
|
308
|
-
// no host worklets are registered AND no capture is active,
|
|
309
|
-
// the entire forwardToIncremental branch (including the
|
|
310
|
-
// ~3-5ms NV21 pack) is skipped — same cost envelope as
|
|
311
|
-
// before Phase 4b.iii.
|
|
312
|
-
if (ingestActive || StitcherWorkletRuntime.hasHostWorklets()) {
|
|
391
|
+
// Forward to the incremental stitcher only when capture is
|
|
392
|
+
// engaged. (The v0.8.0 host-worklet dispatch — which also
|
|
393
|
+
// forwarded preview frames whenever host worklets were
|
|
394
|
+
// registered — was archived in the 2026-06 batch-keyframe
|
|
395
|
+
// cleanup.)
|
|
396
|
+
if (ingestActive) {
|
|
313
397
|
forwardToIncremental(frame, camera)
|
|
314
398
|
}
|
|
315
399
|
|
|
@@ -541,22 +625,14 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
541
625
|
// written to `tmpJpegFile`, passed as `legacyJpegPath`.
|
|
542
626
|
// See the v0.3 / F8.6 entries in CHANGELOG.md.)
|
|
543
627
|
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
// lambda before ARCore recycles the Image.
|
|
552
|
-
StitcherWorkletRuntime.installIfNeeded()
|
|
553
|
-
// v0.8.0 Phase 4b.iii — only run first-party stitching when
|
|
554
|
-
// the host has actively engaged capture (`setIncrementalIngestionActive(true)`).
|
|
555
|
-
// The host-worklet dispatch below runs regardless, so AR-mode
|
|
556
|
-
// preview frames stream through registered host worklets even
|
|
557
|
-
// before/after capture.
|
|
628
|
+
// Synchronous engine ingest. The ARCore Image ownership
|
|
629
|
+
// contract requires the engine to consume the TransferredNV21
|
|
630
|
+
// before ARCore recycles the Image, so this runs inline. Only
|
|
631
|
+
// ingest when the host has actively engaged capture
|
|
632
|
+
// (`setIncrementalIngestionActive(true)`). (The v0.8.0 worklet-
|
|
633
|
+
// runtime `runFirstParty` indirection + host-worklet fan-out
|
|
634
|
+
// were archived in the 2026-06 batch-keyframe cleanup.)
|
|
558
635
|
if (ingestActive) {
|
|
559
|
-
StitcherWorkletRuntime.runFirstParty {
|
|
560
636
|
module.ingestFromARCameraView(
|
|
561
637
|
tx = tArr[0].toDouble(),
|
|
562
638
|
ty = tArr[1].toDouble(),
|
|
@@ -608,42 +684,7 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
608
684
|
) != null
|
|
609
685
|
},
|
|
610
686
|
)
|
|
611
|
-
} // closes StitcherWorkletRuntime.runFirstParty { … } (v0.8.0 Phase 3c)
|
|
612
687
|
} // closes `if (ingestActive)` (v0.8.0 Phase 4b.iii)
|
|
613
|
-
|
|
614
|
-
// ── v0.8.0 Phase 4b.iii — host-worklet fan-out ─────────────
|
|
615
|
-
//
|
|
616
|
-
// Dispatch the AR frame to every host worklet registered via
|
|
617
|
-
// `globalThis.__stitcherProxy.install(workletFn)` (the
|
|
618
|
-
// `useFrameProcessor` hook's AR-mode path). The native side
|
|
619
|
-
// fast-path early-exits when the registry is empty (~ns
|
|
620
|
-
// cost), so this call is free for first-party-only deployments.
|
|
621
|
-
//
|
|
622
|
-
// Map the trackingState back to the JS-visible string set.
|
|
623
|
-
// `RNSARSession.TRACKING_*` are int codes; we re-derive the
|
|
624
|
-
// string here instead of plumbing it through. (Could be
|
|
625
|
-
// refactored into a helper if/when other call sites need
|
|
626
|
-
// it.)
|
|
627
|
-
val trackingStateStr = when (camera.trackingState) {
|
|
628
|
-
TrackingState.TRACKING -> "normal"
|
|
629
|
-
TrackingState.PAUSED -> "limited"
|
|
630
|
-
TrackingState.STOPPED -> "notAvailable"
|
|
631
|
-
else -> ""
|
|
632
|
-
}
|
|
633
|
-
StitcherWorkletRuntime.dispatchToHostWorklets(
|
|
634
|
-
nv21Bytes = packed.nv21,
|
|
635
|
-
width = packed.width,
|
|
636
|
-
height = packed.height,
|
|
637
|
-
qx = qarr[0].toDouble(),
|
|
638
|
-
qy = qarr[1].toDouble(),
|
|
639
|
-
qz = qarr[2].toDouble(),
|
|
640
|
-
qw = qarr[3].toDouble(),
|
|
641
|
-
tx = tArr[0].toDouble(),
|
|
642
|
-
ty = tArr[1].toDouble(),
|
|
643
|
-
tz = tArr[2].toDouble(),
|
|
644
|
-
timestampNs = frame.timestamp.toDouble(),
|
|
645
|
-
trackingState = trackingStateStr,
|
|
646
|
-
)
|
|
647
688
|
}
|
|
648
689
|
|
|
649
690
|
/// v0.13.2 — map the JS physical device orientation to the
|
|
@@ -666,11 +707,28 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
666
707
|
?.defaultDisplay
|
|
667
708
|
?.rotation
|
|
668
709
|
?: Surface.ROTATION_0
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
710
|
+
// Keep lastDisplayRotation current regardless — the JPEG encode
|
|
711
|
+
// path (forwardToIncremental → encodeJpegFromNV21) reads it for
|
|
712
|
+
// the EXIF orientation tag.
|
|
713
|
+
lastDisplayRotation = rotation
|
|
714
|
+
|
|
715
|
+
val box = letterboxBox()
|
|
716
|
+
val bw = box[2]
|
|
717
|
+
val bh = box[3]
|
|
718
|
+
if (bw <= 0 || bh <= 0) return
|
|
719
|
+
// Feed ARCore the BOX dimensions (not the full surface) so its UV
|
|
720
|
+
// transform fills the box aspect — the full camera FOV with no
|
|
721
|
+
// centre-crop. Only push on change to avoid per-frame churn.
|
|
722
|
+
if (rotation != lastGeomRotation || bw != lastGeomW || bh != lastGeomH) {
|
|
723
|
+
session.setDisplayGeometry(rotation, bw, bh)
|
|
724
|
+
lastGeomRotation = rotation
|
|
725
|
+
lastGeomW = bw
|
|
726
|
+
lastGeomH = bh
|
|
727
|
+
Log.d(
|
|
728
|
+
TAG,
|
|
729
|
+
"setDisplayGeometry(box): rotation=$rotation box=${bw}×${bh} "
|
|
730
|
+
+ "surface=${surfaceWidth}×${surfaceHeight} cameraAspect=$cameraAspect",
|
|
731
|
+
)
|
|
674
732
|
}
|
|
675
733
|
}
|
|
676
734
|
|
|
@@ -11,6 +11,8 @@ import com.facebook.react.bridge.ReactApplicationContext
|
|
|
11
11
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
12
12
|
import com.facebook.react.bridge.ReactMethod
|
|
13
13
|
import com.google.ar.core.ArCoreApk
|
|
14
|
+
import com.google.ar.core.CameraConfig
|
|
15
|
+
import com.google.ar.core.CameraConfigFilter
|
|
14
16
|
import com.google.ar.core.Config
|
|
15
17
|
import com.google.ar.core.Plane
|
|
16
18
|
import com.google.ar.core.Pose
|
|
@@ -164,6 +166,7 @@ class RNSARSession(reactContext: ReactApplicationContext)
|
|
|
164
166
|
|
|
165
167
|
val session = sessionRef.get() ?: Session(reactApplicationContext).also {
|
|
166
168
|
sessionRef.set(it)
|
|
169
|
+
selectMatchingCameraConfig(it)
|
|
167
170
|
}
|
|
168
171
|
val config = Config(session).apply {
|
|
169
172
|
// Smoothed depth is the ARCore equivalent of iOS
|
|
@@ -322,6 +325,7 @@ class RNSARSession(reactContext: ReactApplicationContext)
|
|
|
322
325
|
|
|
323
326
|
val session = Session(reactApplicationContext).also {
|
|
324
327
|
sessionRef.set(it)
|
|
328
|
+
selectMatchingCameraConfig(it)
|
|
325
329
|
}
|
|
326
330
|
val config = Config(session).apply {
|
|
327
331
|
if (session.isDepthModeSupported(Config.DepthMode.AUTOMATIC)) {
|
|
@@ -832,6 +836,51 @@ class RNSARSession(reactContext: ReactApplicationContext)
|
|
|
832
836
|
poseLogLock.write { poseLog.clear() }
|
|
833
837
|
}
|
|
834
838
|
|
|
839
|
+
/**
|
|
840
|
+
* Pick an ARCore camera config whose CPU image and GPU texture share
|
|
841
|
+
* the same aspect ratio, so the preview (texture) and the captured /
|
|
842
|
+
* stitched frames (acquireCameraImage) cover the SAME field of view.
|
|
843
|
+
*
|
|
844
|
+
* ARCore's default often pairs a 16:9 GPU texture with a 4:3 CPU
|
|
845
|
+
* image (e.g. 1920x1080 texture + 640x480 image on the Galaxy A35):
|
|
846
|
+
* the texture is then missing ~12 deg of vertical sensor FOV the
|
|
847
|
+
* image has, so the preview can never match the photo. Choosing a
|
|
848
|
+
* config where the two aspects match (preferring 4:3 for max FOV,
|
|
849
|
+
* then the highest image resolution) makes preview == capture by
|
|
850
|
+
* construction -- and usually raises the stitched-frame / photo
|
|
851
|
+
* resolution above 640x480 as a bonus.
|
|
852
|
+
*
|
|
853
|
+
* Must be called on a freshly-created, un-resumed session (ARCore
|
|
854
|
+
* requires the session paused for setCameraConfig). Best-effort: on
|
|
855
|
+
* any failure we keep ARCore's default config.
|
|
856
|
+
*/
|
|
857
|
+
private fun selectMatchingCameraConfig(session: Session) {
|
|
858
|
+
try {
|
|
859
|
+
val configs = session.getSupportedCameraConfigs(CameraConfigFilter(session))
|
|
860
|
+
if (configs.isEmpty()) return
|
|
861
|
+
fun aspect(s: android.util.Size): Float = s.width.toFloat() / s.height.toFloat()
|
|
862
|
+
|
|
863
|
+
val matched = configs.filter {
|
|
864
|
+
kotlin.math.abs(aspect(it.imageSize) - aspect(it.textureSize)) < 0.02f
|
|
865
|
+
}
|
|
866
|
+
val pool = if (matched.isNotEmpty()) matched else configs
|
|
867
|
+
val chosen = pool.sortedWith(
|
|
868
|
+
compareBy<CameraConfig> { kotlin.math.abs(aspect(it.imageSize) - 4f / 3f) }
|
|
869
|
+
.thenByDescending { it.imageSize.width * it.imageSize.height },
|
|
870
|
+
).firstOrNull() ?: return
|
|
871
|
+
session.setCameraConfig(chosen)
|
|
872
|
+
Log.i(
|
|
873
|
+
TAG,
|
|
874
|
+
"selectMatchingCameraConfig: chose image=" +
|
|
875
|
+
"${chosen.imageSize.width}x${chosen.imageSize.height} texture=" +
|
|
876
|
+
"${chosen.textureSize.width}x${chosen.textureSize.height} " +
|
|
877
|
+
"(from ${configs.size} configs, ${matched.size} aspect-matched)",
|
|
878
|
+
)
|
|
879
|
+
} catch (t: Throwable) {
|
|
880
|
+
Log.w(TAG, "selectMatchingCameraConfig failed; keeping default config: ${t.message}")
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
835
884
|
companion object {
|
|
836
885
|
// Mirrors RNSARTrackingState on iOS for cross-platform
|
|
837
886
|
// identical JS behaviour.
|