react-native-image-stitcher 0.15.0 → 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 CHANGED
@@ -16,6 +16,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.15.1] — 2026-06-08
20
+
21
+ ### Fixed
22
+
23
+ - **Camera preview now matches capture FOV on all paths (letterbox WYSIWYG).**
24
+ The preview and captured photo now share the same field of view regardless of
25
+ the container size the host app uses. Black letterbox bars fill any extra
26
+ space rather than cropping or stretching the camera feed.
27
+ - *VisionCamera path:* `CameraView` measures its rendered bounds via
28
+ `onLayout`, pins the format to 4:3 with `useCameraFormat`, then sizes the
29
+ `<Camera>` component to the largest axis-aligned box that fits the container
30
+ while preserving the format aspect ratio.
31
+ - *ARCore path (Android):* `RNSARCameraView` now selects a camera config
32
+ whose image aspect and texture aspect match within 2% (`selectMatchingCameraConfig`).
33
+ On devices (e.g. Galaxy A35) where no 4:3 matched config exists, the best
34
+ available 16:9 config is chosen — both preview and capture are 16:9.
35
+ The GL renderer letterboxes the camera texture inside the GL surface using
36
+ `setDisplayGeometry` + `glViewport`, centred on a black-cleared surface.
37
+ - *ARKit path (iOS):* `RNSARCameraView.layoutSubviews()` reads
38
+ `imageResolution` from the ARKit session and centres the scene view inside
39
+ the container bounds using the same aspect-correct letterbox calculation.
40
+
41
+ - **ARCore CPU image resolution upgraded automatically.** `selectMatchingCameraConfig`
42
+ prefers the highest-resolution matched config, so CPU image captures used for
43
+ stitching are now at full sensor resolution (1920×1080 on the Galaxy A35,
44
+ up from 640×480) with no API change required.
45
+
46
+ ### Changed
47
+
48
+ - **`defaultCaptureSource` changed from `'ar'` to `'non-ar'`.** AR mode is now
49
+ opt-in. Host apps that want AR must pass `defaultCaptureSource="ar"` or
50
+ implement a toggle; the plain camera path is the default.
51
+
19
52
  ## [0.15.0] — 2026-06-07
20
53
 
21
54
  ### Breaking — only `batch-keyframe` remains; host-worklet / frame-stream hooks removed
@@ -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)
@@ -615,11 +707,28 @@ class RNSARCameraView @JvmOverloads constructor(
615
707
  ?.defaultDisplay
616
708
  ?.rotation
617
709
  ?: Surface.ROTATION_0
618
- if (rotation != lastDisplayRotation
619
- || surfaceWidth > 0 || surfaceHeight > 0
620
- ) {
621
- session.setDisplayGeometry(rotation, surfaceWidth, surfaceHeight)
622
- 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
+ )
623
732
  }
624
733
  }
625
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.
@@ -281,7 +281,7 @@ function extractPanoramaOverrides(props) {
281
281
  * The public `<Camera>` component.
282
282
  */
283
283
  function Camera(props) {
284
- const { defaultCaptureSource = 'ar', defaultLens = '1x', captureSources = 'both', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, outputDir, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, onCaptureAbandoned, flash: controlledFlash, onFlashChange, showFlashButton = true, headerTitle, onHeaderBack, headerBackLabel, headerGuidance, headerColors, thumbnails, thumbnailsMin, thumbnailsMax, onThumbnailPress, capturePreview, capturePreviewActions, onCapturePreviewClose, frameProcessor: hostFrameProcessor, engine = 'batch-keyframe', } = props;
284
+ const { defaultCaptureSource = 'non-ar', defaultLens = '1x', captureSources = 'both', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, outputDir, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, onCaptureAbandoned, flash: controlledFlash, onFlashChange, showFlashButton = true, headerTitle, onHeaderBack, headerBackLabel, headerGuidance, headerColors, thumbnails, thumbnailsMin, thumbnailsMax, onThumbnailPress, capturePreview, capturePreviewActions, onCapturePreviewClose, frameProcessor: hostFrameProcessor, engine = 'batch-keyframe', } = props;
285
285
  // v0.13.2 — capture-source constraint (default 'both'). Derives which
286
286
  // sources are permitted; `captureSources` overrides any conflicting
287
287
  // `defaultCaptureSource`. Used to constrain the initial AR preference
@@ -92,12 +92,73 @@ exports.CameraView = (0, react_1.forwardRef)(function CameraView({ device, flash
92
92
  // Internal ref so we can both attach to <Camera> and forward outward.
93
93
  const innerRef = (0, react_1.useRef)(null);
94
94
  (0, react_1.useImperativeHandle)(ref, () => innerRef.current);
95
+ // ── WYSIWYG letterboxing ────────────────────────────────────────
96
+ //
97
+ // Pin BOTH the photo and the preview (video) stream to a 4:3 aspect
98
+ // ratio so the viewport shows exactly what gets captured. Without a
99
+ // pinned format, vision-camera picks the device default for each —
100
+ // commonly a 4:3 photo but a 16:9 preview — so the preview and the
101
+ // saved frame frame different scenes. 4:3 is the native still
102
+ // aspect on essentially every phone camera (incl. ultra-wide), so a
103
+ // matching format is virtually always available; `useCameraFormat`
104
+ // returns the closest match and never throws.
105
+ const format = (0, react_native_vision_camera_1.useCameraFormat)(device ?? undefined, [
106
+ { photoAspectRatio: 4 / 3 },
107
+ { videoAspectRatio: 4 / 3 },
108
+ ]);
109
+ // Measured size of our container, so we can size the <Camera> view to
110
+ // the largest box of the capture's aspect ratio that fits inside it
111
+ // (the rest becomes the black letterbox). We deliberately size the
112
+ // VIEW rather than relying on vision-camera's `resizeMode` alone:
113
+ // resizeMode maps to PreviewView.ScaleType on Android, which several
114
+ // devices ignore under the default SurfaceView compositor — so the
115
+ // preview kept filling the screen. When the view's own aspect ratio
116
+ // equals the feed's, there is nothing left to crop on any platform.
117
+ const [size, setSize] = (0, react_1.useState)(null);
118
+ const onRootLayout = (0, react_1.useCallback)((e) => {
119
+ const { width, height } = e.nativeEvent.layout;
120
+ setSize((prev) => prev && prev.w === width && prev.h === height
121
+ ? prev
122
+ : { w: width, h: height });
123
+ }, []);
95
124
  if (!device) {
96
125
  return (react_1.default.createElement(react_native_1.View, { style: [styles.placeholder, style], accessibilityLabel: "Camera initialising" },
97
126
  react_1.default.createElement(react_native_1.Text, { style: styles.placeholderText }, "Initialising camera\u2026")));
98
127
  }
99
- return (react_1.default.createElement(react_native_1.View, { style: [styles.root, style] },
100
- react_1.default.createElement(react_native_vision_camera_1.Camera, { ref: innerRef, style: react_native_1.StyleSheet.absoluteFill, device: device, isActive: isActive, photo: true, video: video, ...(zoom != null ? { zoom } : {}),
128
+ // Capture aspect ratio (W÷H) in the sensor's native landscape
129
+ // orientation (so > 1). Falls back to 4:3 until the format resolves.
130
+ const sensorAspect = format && format.photoWidth > 0 && format.photoHeight > 0
131
+ ? format.photoWidth / format.photoHeight
132
+ : 4 / 3;
133
+ // With outputOrientation="device", a portrait device displays the
134
+ // scene rotated, so the on-screen content aspect is the inverse of
135
+ // the landscape sensor aspect. Detect portrait from the measured
136
+ // container — robust across devices, split-screen and rotation.
137
+ const isPortrait = size != null ? size.h >= size.w : true;
138
+ const contentAspect = isPortrait ? 1 / sensorAspect : sensorAspect;
139
+ // Largest box of `contentAspect` that fits the container, centred by
140
+ // styles.root. The remaining area is the black letterbox. Before the
141
+ // first onLayout we fill the container so the camera session mounts
142
+ // immediately; the exact box snaps in ~1 frame later.
143
+ let cameraStyle;
144
+ if (size == null || size.w === 0 || size.h === 0) {
145
+ cameraStyle = react_native_1.StyleSheet.absoluteFillObject;
146
+ }
147
+ else {
148
+ const heightIfFullWidth = size.w / contentAspect;
149
+ cameraStyle =
150
+ heightIfFullWidth <= size.h
151
+ ? { width: size.w, height: heightIfFullWidth }
152
+ : { width: size.h * contentAspect, height: size.h };
153
+ }
154
+ return (react_1.default.createElement(react_native_1.View, { style: [styles.root, style], onLayout: onRootLayout },
155
+ react_1.default.createElement(react_native_vision_camera_1.Camera, { ref: innerRef,
156
+ // Sized to the letterboxed box (capture aspect ratio) so the
157
+ // preview never crops; styles.root centres it and paints the
158
+ // surrounding bars black. See the cameraStyle computation above.
159
+ style: cameraStyle, device: device, isActive: isActive, photo: true, video: video,
160
+ // Pin preview + photo to the same 4:3 format (WYSIWYG capture).
161
+ format: format, ...(zoom != null ? { zoom } : {}),
101
162
  // Bake the device orientation into the captured pixels.
102
163
  // Without this, vision-camera writes the file in the camera
103
164
  // sensor's native landscape and relies on EXIF metadata to
@@ -107,7 +168,26 @@ exports.CameraView = (0, react_1.forwardRef)(function CameraView({ device, flash
107
168
  // `outputOrientation="device"` rotates the pixels to match
108
169
  // how the user is holding the phone, so the saved JPEG is
109
170
  // "what you see is what was taken".
110
- outputOrientation: "device", torch: flash === 'on' ? 'on' : 'off', onError: handleVcError, ...cameraProps }),
171
+ outputOrientation: "device",
172
+ // Show the full camera FOV — no cropping. 'contain' maps to
173
+ // AVLayerVideoGravity.resizeAspect on iOS and the equivalent
174
+ // on Android, letterboxing the preview to the sensor's exact
175
+ // aspect ratio. Without this the default 'cover' crops
176
+ // ~19% off each horizontal edge in portrait mode (4:3 sensor
177
+ // in a 9:21 viewport), so the stitcher receives frames the
178
+ // user never saw. Black bars fill the remainder; backgroundColor
179
+ // on styles.root ensures they are always black.
180
+ resizeMode: "contain",
181
+ // Android: force TextureView rendering so that FIT_CENTER
182
+ // (the Android equivalent of resizeMode="contain") actually
183
+ // produces visible letterboxing. The default SurfaceView mode
184
+ // composes at the hardware layer below the View hierarchy and
185
+ // on many devices ignores FIT_CENTER, filling the full surface
186
+ // instead. TextureView is part of the regular View hierarchy
187
+ // so the matrix transform for FIT_CENTER works correctly —
188
+ // the bars outside the letterboxed area are transparent,
189
+ // revealing the parent's black backgroundColor.
190
+ androidPreviewViewType: "texture-view", torch: flash === 'on' ? 'on' : 'off', onError: handleVcError, ...cameraProps }),
111
191
  guidance ? (react_1.default.createElement(react_native_1.View, { style: styles.guidance, pointerEvents: "none", accessible: true, accessibilityRole: "text" },
112
192
  react_1.default.createElement(react_native_1.Text, { style: styles.guidanceText, numberOfLines: 2 }, guidance))) : null));
113
193
  });
@@ -115,6 +195,16 @@ const styles = react_native_1.StyleSheet.create({
115
195
  root: {
116
196
  flex: 1,
117
197
  overflow: 'hidden',
198
+ // Centre the letterboxed <Camera> box so the black bars are
199
+ // symmetric on both sides (top/bottom in portrait, left/right in
200
+ // landscape).
201
+ alignItems: 'center',
202
+ justifyContent: 'center',
203
+ // Black bars when the camera's aspect ratio doesn't fill the
204
+ // container (e.g. 4:3 sensor in a 9:21 portrait viewport). Without
205
+ // this the bars are transparent, revealing whatever is behind the
206
+ // component.
207
+ backgroundColor: '#000',
118
208
  },
119
209
  placeholder: {
120
210
  flex: 1,
@@ -54,7 +54,10 @@ public final class RNSARCameraView: UIView {
54
54
 
55
55
  private func setupView() {
56
56
  arSCNView = ARSCNView(frame: bounds)
57
- arSCNView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
57
+ // Do NOT set autoresizingMask we manage the ARSCNView frame
58
+ // manually in layoutSubviews() to achieve letterboxing.
59
+ // autoresizingMask would fight that and re-expand the view to
60
+ // fill our bounds on every Auto Layout pass.
58
61
 
59
62
  // Bind to the singleton's session. This is the critical
60
63
  // line — without it, ARSCNView would try to create its own
@@ -71,18 +74,84 @@ public final class RNSARCameraView: UIView {
71
74
  arSCNView.showsStatistics = false
72
75
  arSCNView.automaticallyUpdatesLighting = false
73
76
 
74
- // Black background while ARKit is initialising so the user
75
- // sees a clean frame instead of whatever was there before.
77
+ // Black background: fills the letterbox bars (the areas of
78
+ // this view outside ARSCNView's letterboxed sub-rect).
76
79
  backgroundColor = .black
77
80
  addSubview(arSCNView)
78
81
  }
79
82
 
80
83
  public override func layoutSubviews() {
81
84
  super.layoutSubviews()
82
- // RN's flexbox can re-bound this view at any time; keep the
83
- // ARSCNView locked to our bounds. autoresizingMask handles
84
- // most cases but isn't always enough on rotation transitions.
85
- arSCNView.frame = bounds
85
+ // Letterbox the ARSCNView to show the full camera FOV.
86
+ //
87
+ // ARSCNView's internal renderer always uses resizeAspectFill
88
+ // (fills its view, crops if aspect ratios differ). If we give
89
+ // it our full bounds (portrait 9:21) and the camera image is
90
+ // effectively portrait 3:4 (4:3 sensor rotated for device
91
+ // orientation), it crops ~19% off each horizontal edge —
92
+ // exactly the "viewport ≠ captured frame" bug.
93
+ //
94
+ // Fix: set ARSCNView's frame to the largest rect inside our
95
+ // bounds that has the camera's content aspect ratio. When
96
+ // ARSCNView fills a same-AR sub-rect, there is nothing to crop
97
+ // and the user sees the full captured scene. The parent view's
98
+ // black background fills the remainder.
99
+ arSCNView.frame = letterboxedFrame()
100
+ }
101
+
102
+ /// Returns the largest `CGRect` inside `bounds` that matches the
103
+ /// camera's content aspect ratio (accounting for device orientation),
104
+ /// centred within `bounds`.
105
+ private func letterboxedFrame() -> CGRect {
106
+ let aspect = cameraContentAspect()
107
+ let bw = bounds.width
108
+ let bh = bounds.height
109
+ guard bw > 0, bh > 0, aspect > 0 else { return bounds }
110
+
111
+ // Try fitting by width first; if height overflows, fit by height.
112
+ let hByWidth = bw / aspect
113
+ if hByWidth <= bh {
114
+ // Content fits within height — horizontal bars top+bottom.
115
+ let y = (bh - hByWidth) / 2
116
+ return CGRect(x: 0, y: y, width: bw, height: hByWidth)
117
+ } else {
118
+ // Vertical bars left+right.
119
+ let wByHeight = bh * aspect
120
+ let x = (bw - wByHeight) / 2
121
+ return CGRect(x: x, y: 0, width: wByHeight, height: bh)
122
+ }
123
+ }
124
+
125
+ /// Camera content aspect ratio (W÷H) in the current device orientation.
126
+ ///
127
+ /// The ARKit sensor is physically landscape (e.g. 1920 × 1440, aspect 4/3).
128
+ /// When the device is portrait the ARSCNView displays the scene rotated,
129
+ /// so the effective content aspect is 3/4. We invert accordingly so the
130
+ /// letterboxed frame always reflects what the user is actually looking at.
131
+ ///
132
+ /// Source priority:
133
+ /// 1. `currentFrame.camera.imageResolution` — live, most accurate.
134
+ /// 2. Active session config's `videoFormat.imageResolution` — stable
135
+ /// after `arSession.run(…)` and before the first frame.
136
+ /// 3. 4:3 hardcoded fallback — correct for every iPhone ARKit camera.
137
+ private func cameraContentAspect() -> CGFloat {
138
+ let rawResolution: CGSize? = {
139
+ if let res = RNSARSession.shared.arSession.currentFrame?.camera.imageResolution {
140
+ return CGSize(width: res.width, height: res.height)
141
+ }
142
+ if let fmt = (RNSARSession.shared.arSession.configuration as? ARWorldTrackingConfiguration)?.videoFormat {
143
+ return CGSize(width: fmt.imageResolution.width, height: fmt.imageResolution.height)
144
+ }
145
+ return nil
146
+ }()
147
+
148
+ // Raw sensor aspect (always landscape > 1 for iPhone cameras).
149
+ let sensorAspect: CGFloat = rawResolution.map { $0.width / $0.height } ?? (4.0 / 3.0)
150
+
151
+ // In portrait mode (view taller than wide) the displayed scene
152
+ // is effectively portrait → invert the sensor aspect.
153
+ let deviceIsPortrait = bounds.height > bounds.width
154
+ return deviceIsPortrait ? (1.0 / sensorAspect) : sensorAspect
86
155
  }
87
156
 
88
157
  public override func didMoveToWindow() {
@@ -99,6 +168,12 @@ public final class RNSARCameraView: UIView {
99
168
  if !RNSARSession.shared.isRunning {
100
169
  RNSARSession.shared.start()
101
170
  }
171
+ // Re-layout after session start: the configuration's
172
+ // videoFormat (and shortly after, currentFrame) are now
173
+ // available for a more accurate aspect ratio. On iOS all
174
+ // ARKit cameras are 4:3 so this is a no-op in practice,
175
+ // but it keeps the code correct for future configs.
176
+ setNeedsLayout()
102
177
  } else {
103
178
  // Removed from window — stop the session. Don't clear
104
179
  // the pose log here; the host explicitly clears between
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -899,7 +899,7 @@ function extractPanoramaOverrides(props: CameraProps): PanoramaPropOverrides {
899
899
  */
900
900
  export function Camera(props: CameraProps): React.JSX.Element {
901
901
  const {
902
- defaultCaptureSource = 'ar',
902
+ defaultCaptureSource = 'non-ar',
903
903
  defaultLens = '1x',
904
904
  captureSources = 'both',
905
905
  enablePhotoMode = true,
@@ -19,10 +19,23 @@
19
19
  * UI can still use it as their building block.
20
20
  */
21
21
 
22
- import React, { forwardRef, useImperativeHandle, useRef } from 'react';
23
- import { StyleSheet, Text, View, type ViewStyle } from 'react-native';
22
+ import React, {
23
+ forwardRef,
24
+ useCallback,
25
+ useImperativeHandle,
26
+ useRef,
27
+ useState,
28
+ } from 'react';
29
+ import {
30
+ StyleSheet,
31
+ Text,
32
+ View,
33
+ type LayoutChangeEvent,
34
+ type ViewStyle,
35
+ } from 'react-native';
24
36
  import {
25
37
  Camera,
38
+ useCameraFormat,
26
39
  type CameraDevice,
27
40
  type CameraProps,
28
41
  } from 'react-native-vision-camera';
@@ -140,6 +153,39 @@ export const CameraView = forwardRef<Camera | null, CameraViewProps>(function Ca
140
153
  const innerRef = useRef<Camera>(null);
141
154
  useImperativeHandle(ref, () => innerRef.current as Camera);
142
155
 
156
+ // ── WYSIWYG letterboxing ────────────────────────────────────────
157
+ //
158
+ // Pin BOTH the photo and the preview (video) stream to a 4:3 aspect
159
+ // ratio so the viewport shows exactly what gets captured. Without a
160
+ // pinned format, vision-camera picks the device default for each —
161
+ // commonly a 4:3 photo but a 16:9 preview — so the preview and the
162
+ // saved frame frame different scenes. 4:3 is the native still
163
+ // aspect on essentially every phone camera (incl. ultra-wide), so a
164
+ // matching format is virtually always available; `useCameraFormat`
165
+ // returns the closest match and never throws.
166
+ const format = useCameraFormat(device ?? undefined, [
167
+ { photoAspectRatio: 4 / 3 },
168
+ { videoAspectRatio: 4 / 3 },
169
+ ]);
170
+
171
+ // Measured size of our container, so we can size the <Camera> view to
172
+ // the largest box of the capture's aspect ratio that fits inside it
173
+ // (the rest becomes the black letterbox). We deliberately size the
174
+ // VIEW rather than relying on vision-camera's `resizeMode` alone:
175
+ // resizeMode maps to PreviewView.ScaleType on Android, which several
176
+ // devices ignore under the default SurfaceView compositor — so the
177
+ // preview kept filling the screen. When the view's own aspect ratio
178
+ // equals the feed's, there is nothing left to crop on any platform.
179
+ const [size, setSize] = useState<{ w: number; h: number } | null>(null);
180
+ const onRootLayout = useCallback((e: LayoutChangeEvent) => {
181
+ const { width, height } = e.nativeEvent.layout;
182
+ setSize((prev) =>
183
+ prev && prev.w === width && prev.h === height
184
+ ? prev
185
+ : { w: width, h: height },
186
+ );
187
+ }, []);
188
+
143
189
  if (!device) {
144
190
  return (
145
191
  <View style={[styles.placeholder, style]} accessibilityLabel="Camera initialising">
@@ -148,15 +194,49 @@ export const CameraView = forwardRef<Camera | null, CameraViewProps>(function Ca
148
194
  );
149
195
  }
150
196
 
197
+ // Capture aspect ratio (W÷H) in the sensor's native landscape
198
+ // orientation (so > 1). Falls back to 4:3 until the format resolves.
199
+ const sensorAspect =
200
+ format && format.photoWidth > 0 && format.photoHeight > 0
201
+ ? format.photoWidth / format.photoHeight
202
+ : 4 / 3;
203
+
204
+ // With outputOrientation="device", a portrait device displays the
205
+ // scene rotated, so the on-screen content aspect is the inverse of
206
+ // the landscape sensor aspect. Detect portrait from the measured
207
+ // container — robust across devices, split-screen and rotation.
208
+ const isPortrait = size != null ? size.h >= size.w : true;
209
+ const contentAspect = isPortrait ? 1 / sensorAspect : sensorAspect;
210
+
211
+ // Largest box of `contentAspect` that fits the container, centred by
212
+ // styles.root. The remaining area is the black letterbox. Before the
213
+ // first onLayout we fill the container so the camera session mounts
214
+ // immediately; the exact box snaps in ~1 frame later.
215
+ let cameraStyle: ViewStyle;
216
+ if (size == null || size.w === 0 || size.h === 0) {
217
+ cameraStyle = StyleSheet.absoluteFillObject;
218
+ } else {
219
+ const heightIfFullWidth = size.w / contentAspect;
220
+ cameraStyle =
221
+ heightIfFullWidth <= size.h
222
+ ? { width: size.w, height: heightIfFullWidth }
223
+ : { width: size.h * contentAspect, height: size.h };
224
+ }
225
+
151
226
  return (
152
- <View style={[styles.root, style]}>
227
+ <View style={[styles.root, style]} onLayout={onRootLayout}>
153
228
  <Camera
154
229
  ref={innerRef}
155
- style={StyleSheet.absoluteFill}
230
+ // Sized to the letterboxed box (capture aspect ratio) so the
231
+ // preview never crops; styles.root centres it and paints the
232
+ // surrounding bars black. See the cameraStyle computation above.
233
+ style={cameraStyle}
156
234
  device={device}
157
235
  isActive={isActive}
158
236
  photo
159
237
  video={video}
238
+ // Pin preview + photo to the same 4:3 format (WYSIWYG capture).
239
+ format={format}
160
240
  // v0.13.2 — multi-cam lens switch via zoom (undefined = default).
161
241
  {...(zoom != null ? { zoom } : {})}
162
242
  // Bake the device orientation into the captured pixels.
@@ -169,6 +249,25 @@ export const CameraView = forwardRef<Camera | null, CameraViewProps>(function Ca
169
249
  // how the user is holding the phone, so the saved JPEG is
170
250
  // "what you see is what was taken".
171
251
  outputOrientation="device"
252
+ // Show the full camera FOV — no cropping. 'contain' maps to
253
+ // AVLayerVideoGravity.resizeAspect on iOS and the equivalent
254
+ // on Android, letterboxing the preview to the sensor's exact
255
+ // aspect ratio. Without this the default 'cover' crops
256
+ // ~19% off each horizontal edge in portrait mode (4:3 sensor
257
+ // in a 9:21 viewport), so the stitcher receives frames the
258
+ // user never saw. Black bars fill the remainder; backgroundColor
259
+ // on styles.root ensures they are always black.
260
+ resizeMode="contain"
261
+ // Android: force TextureView rendering so that FIT_CENTER
262
+ // (the Android equivalent of resizeMode="contain") actually
263
+ // produces visible letterboxing. The default SurfaceView mode
264
+ // composes at the hardware layer below the View hierarchy and
265
+ // on many devices ignores FIT_CENTER, filling the full surface
266
+ // instead. TextureView is part of the regular View hierarchy
267
+ // so the matrix transform for FIT_CENTER works correctly —
268
+ // the bars outside the letterboxed area are transparent,
269
+ // revealing the parent's black backgroundColor.
270
+ androidPreviewViewType="texture-view"
172
271
  torch={flash === 'on' ? 'on' : 'off'}
173
272
  onError={handleVcError}
174
273
  {...cameraProps}
@@ -189,6 +288,16 @@ const styles = StyleSheet.create({
189
288
  root: {
190
289
  flex: 1,
191
290
  overflow: 'hidden',
291
+ // Centre the letterboxed <Camera> box so the black bars are
292
+ // symmetric on both sides (top/bottom in portrait, left/right in
293
+ // landscape).
294
+ alignItems: 'center',
295
+ justifyContent: 'center',
296
+ // Black bars when the camera's aspect ratio doesn't fill the
297
+ // container (e.g. 4:3 sensor in a 9:21 portrait viewport). Without
298
+ // this the bars are transparent, revealing whatever is behind the
299
+ // component.
300
+ backgroundColor: '#000',
192
301
  },
193
302
  placeholder: {
194
303
  flex: 1,