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.
Files changed (120) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +35 -0
  3. package/RNImageStitcher.podspec +8 -7
  4. package/android/build.gradle +0 -16
  5. package/android/src/main/cpp/CMakeLists.txt +2 -63
  6. package/android/src/main/cpp/image_stitcher_jni.cpp +14 -0
  7. package/android/src/main/cpp/keyframe_gate_jni.cpp +13 -0
  8. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +285 -3
  9. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +180 -1162
  10. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +29 -0
  11. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +0 -4
  12. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +129 -71
  13. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +49 -0
  14. package/cpp/keyframe_gate.cpp +82 -23
  15. package/cpp/keyframe_gate.hpp +31 -2
  16. package/cpp/stitcher.cpp +208 -28
  17. package/cpp/tests/CMakeLists.txt +18 -12
  18. package/cpp/tests/keyframe_timebudget_test.cpp +65 -0
  19. package/cpp/tests/warp_guard_test.cpp +48 -0
  20. package/cpp/warp_guard.hpp +41 -0
  21. package/dist/camera/Camera.d.ts +31 -16
  22. package/dist/camera/Camera.js +11 -3
  23. package/dist/camera/CameraView.js +93 -3
  24. package/dist/camera/CaptureStitchStatsToast.d.ts +15 -2
  25. package/dist/camera/CaptureStitchStatsToast.js +27 -7
  26. package/dist/camera/PanoramaSettings.d.ts +10 -223
  27. package/dist/camera/PanoramaSettings.js +6 -28
  28. package/dist/camera/PanoramaSettingsBridge.d.ts +1 -24
  29. package/dist/camera/PanoramaSettingsBridge.js +3 -102
  30. package/dist/camera/PanoramaSettingsModal.js +7 -1
  31. package/dist/camera/buildPanoramaInitialSettings.d.ts +11 -0
  32. package/dist/camera/buildPanoramaInitialSettings.js +4 -0
  33. package/dist/camera/cameraErrorMessages.d.ts +32 -0
  34. package/dist/camera/cameraErrorMessages.js +53 -0
  35. package/dist/camera/selectCaptureDevice.d.ts +5 -1
  36. package/dist/camera/selectCaptureDevice.js +22 -2
  37. package/dist/camera/useCapture.js +38 -0
  38. package/dist/index.d.ts +5 -8
  39. package/dist/index.js +11 -34
  40. package/dist/stitching/incremental.d.ts +1 -117
  41. package/dist/stitching/stitchVideo.d.ts +0 -35
  42. package/dist/types.d.ts +0 -87
  43. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +96 -674
  44. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +9 -12
  45. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +14 -0
  46. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +7 -0
  47. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +6 -0
  48. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +2 -2
  49. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +3 -3
  50. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +28 -60
  51. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +180 -921
  52. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +82 -7
  53. package/ios/Sources/RNImageStitcher/RNSARSession.swift +10 -35
  54. package/ios/Sources/RNImageStitcher/Stitcher.swift +84 -35
  55. package/ios/Sources/RNImageStitcher/StitcherBridge.m +13 -0
  56. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +132 -5
  57. package/package.json +3 -2
  58. package/src/camera/Camera.tsx +44 -23
  59. package/src/camera/CameraView.tsx +113 -4
  60. package/src/camera/CaptureStitchStatsToast.tsx +58 -14
  61. package/src/camera/PanoramaSettings.ts +16 -289
  62. package/src/camera/PanoramaSettingsBridge.ts +3 -114
  63. package/src/camera/PanoramaSettingsModal.tsx +14 -1
  64. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +3 -188
  65. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +41 -0
  66. package/src/camera/__tests__/cameraErrorMessages.test.ts +76 -0
  67. package/src/camera/__tests__/selectCaptureDevice.test.ts +33 -0
  68. package/src/camera/buildPanoramaInitialSettings.ts +17 -0
  69. package/src/camera/cameraErrorMessages.ts +84 -0
  70. package/src/camera/selectCaptureDevice.ts +28 -3
  71. package/src/camera/useCapture.ts +44 -1
  72. package/src/index.ts +11 -40
  73. package/src/stitching/incremental.ts +3 -140
  74. package/src/stitching/stitchVideo.ts +0 -26
  75. package/src/types.ts +0 -95
  76. package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +0 -227
  77. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +0 -1081
  78. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +0 -103
  79. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +0 -256
  80. package/cpp/stitcher_frame_jsi.cpp +0 -214
  81. package/cpp/stitcher_frame_jsi.hpp +0 -108
  82. package/cpp/stitcher_proxy_jsi.cpp +0 -109
  83. package/cpp/stitcher_proxy_jsi.hpp +0 -46
  84. package/cpp/stitcher_worklet_dispatch.cpp +0 -103
  85. package/cpp/stitcher_worklet_dispatch.hpp +0 -71
  86. package/cpp/stitcher_worklet_registry.cpp +0 -91
  87. package/cpp/stitcher_worklet_registry.hpp +0 -146
  88. package/cpp/tests/stitcher_worklet_registry_test.cpp +0 -195
  89. package/dist/stitching/IncrementalStitcherView.d.ts +0 -41
  90. package/dist/stitching/IncrementalStitcherView.js +0 -157
  91. package/dist/stitching/StitcherWorkletRegistry.d.ts +0 -117
  92. package/dist/stitching/StitcherWorkletRegistry.js +0 -78
  93. package/dist/stitching/ensureStitcherProxyInstalled.d.ts +0 -8
  94. package/dist/stitching/ensureStitcherProxyInstalled.js +0 -81
  95. package/dist/stitching/useFrameProcessor.d.ts +0 -119
  96. package/dist/stitching/useFrameProcessor.js +0 -196
  97. package/dist/stitching/useFrameStream.d.ts +0 -34
  98. package/dist/stitching/useFrameStream.js +0 -234
  99. package/dist/stitching/useThrottledFrameProcessor.d.ts +0 -33
  100. package/dist/stitching/useThrottledFrameProcessor.js +0 -132
  101. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +0 -474
  102. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +0 -1328
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +0 -103
  104. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +0 -3285
  105. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +0 -128
  106. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +0 -313
  107. package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +0 -185
  108. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +0 -60
  109. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +0 -214
  110. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +0 -42
  111. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +0 -160
  112. package/src/stitching/IncrementalStitcherView.tsx +0 -198
  113. package/src/stitching/StitcherWorkletRegistry.ts +0 -156
  114. package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +0 -176
  115. package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +0 -94
  116. package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +0 -178
  117. package/src/stitching/ensureStitcherProxyInstalled.ts +0 -141
  118. package/src/stitching/useFrameProcessor.ts +0 -226
  119. package/src/stitching/useFrameStream.ts +0 -271
  120. 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 if engaged, OR if any
300
- // host worklets are registered (v0.8.0 Phase 4b.iii). iOS'
301
- // `RNSARWorkletRuntime.dispatchFrame:pose:` fires on every
302
- // AR frame regardless of capture state; Android needs the
303
- // same semantic so host worklets see the AR-mode preview
304
- // stream, not just capture frames.
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
- // v0.8.0 Phase 3c route through the worklet runtime's
545
- // `runFirstParty` indirection. The lambda body is the
546
- // unchanged engine ingest call; the indirection sets up
547
- // the seam where Phase 4 will fan out to host worklets
548
- // without touching this first-party path. Synchronous
549
- // invocation preserves the ARCore Image ownership contract
550
- // the engine consumes the TransferredNV21 inside the
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
- if (rotation != lastDisplayRotation
670
- || surfaceWidth > 0 || surfaceHeight > 0
671
- ) {
672
- session.setDisplayGeometry(rotation, surfaceWidth, surfaceHeight)
673
- lastDisplayRotation = rotation
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.