react-native-image-stitcher 0.15.0 → 0.15.2

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,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.15.2] — 2026-06-11
20
+
21
+ ### Fixed
22
+
23
+ - **Sharp non-AR camera preview (WYSIWYG follow-up).** The v0.15.1
24
+ letterbox pinned the vision-camera format by aspect ratio only, so
25
+ `useCameraFormat` could settle on a degenerate 4:3 format — observed as
26
+ a 192×144 video stream on the iPhone 16 Pro — rendering the preview as
27
+ upscaled mush behind a full-resolution capture. The format filter now
28
+ also requests `{ videoResolution: 'max' }`, so among 4:3 formats the
29
+ highest-resolution one is chosen: a sharp preview plus full-res frames
30
+ into the non-AR stitcher, with aspect kept as the top-priority filter so
31
+ 4:3 capture parity holds. A bounded target (e.g. 1920×1440) is
32
+ deliberately avoided — the nearest such format on the iPhone 16 Pro is
33
+ 10-bit-only (`x420`/`x422`), which the frame processor's 8-bit
34
+ `420v`/`420f` pipeline rejects with `device/pixel-format-not-supported`;
35
+ vision-camera exposes no per-format pixel formats to JS, so `'max'`
36
+ (empirically the device's 8-bit full-res format) is the robust choice.
37
+ Tap-to-photo stills are capped at ~12 MP (`photoResolution: 4032×3024`,
38
+ lowest priority) so the iPhone 16 Pro's max-video format doesn't default
39
+ to a 24 MP still — the panorama path uses the video stream, not
40
+ `takePhoto`, so the cap costs nothing there.
41
+
42
+ ## [0.15.1] — 2026-06-08
43
+
44
+ ### Fixed
45
+
46
+ - **Camera preview now matches capture FOV on all paths (letterbox WYSIWYG).**
47
+ The preview and captured photo now share the same field of view regardless of
48
+ the container size the host app uses. Black letterbox bars fill any extra
49
+ space rather than cropping or stretching the camera feed.
50
+ - *VisionCamera path:* `CameraView` measures its rendered bounds via
51
+ `onLayout`, pins the format to 4:3 with `useCameraFormat`, then sizes the
52
+ `<Camera>` component to the largest axis-aligned box that fits the container
53
+ while preserving the format aspect ratio.
54
+ - *ARCore path (Android):* `RNSARCameraView` now selects a camera config
55
+ whose image aspect and texture aspect match within 2% (`selectMatchingCameraConfig`).
56
+ On devices (e.g. Galaxy A35) where no 4:3 matched config exists, the best
57
+ available 16:9 config is chosen — both preview and capture are 16:9.
58
+ The GL renderer letterboxes the camera texture inside the GL surface using
59
+ `setDisplayGeometry` + `glViewport`, centred on a black-cleared surface.
60
+ - *ARKit path (iOS):* `RNSARCameraView.layoutSubviews()` reads
61
+ `imageResolution` from the ARKit session and centres the scene view inside
62
+ the container bounds using the same aspect-correct letterbox calculation.
63
+
64
+ - **ARCore CPU image resolution upgraded automatically.** `selectMatchingCameraConfig`
65
+ prefers the highest-resolution matched config, so CPU image captures used for
66
+ stitching are now at full sensor resolution (1920×1080 on the Galaxy A35,
67
+ up from 640×480) with no API change required.
68
+
69
+ ### Changed
70
+
71
+ - **`defaultCaptureSource` changed from `'ar'` to `'non-ar'`.** AR mode is now
72
+ opt-in. Host apps that want AR must pass `defaultCaptureSource="ar"` or
73
+ implement a toggle; the plain camera path is the default.
74
+
19
75
  ## [0.15.0] — 2026-06-07
20
76
 
21
77
  ### 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,111 @@ 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
+ //
106
+ // Resolution preference matters too: filtering on aspect ALONE lets
107
+ // vision-camera settle on whatever 4:3 format sorts first — observed as
108
+ // a 192×144 VIDEO stream on the iPhone 16 Pro (the photo still uses the
109
+ // format's full-res photo dims, so you'd get a sharp capture behind a
110
+ // mush preview). So we also request the highest video resolution.
111
+ //
112
+ // Why `'max'` and not a bounded target like 1920×1440? We tried the
113
+ // bounded target and it FAILED on the iPhone 16 Pro: the nearest
114
+ // 1920×1440 format is a 10-bit format (pixel formats x420 / x422 only —
115
+ // and it is NOT flagged HDR, so the `videoHdr` filter can't dodge it).
116
+ // The frame processor + the stitcher's CV pipeline need 8-bit
117
+ // `420v`/`420f`, so vision-camera raises
118
+ // `device/pixel-format-not-supported` and silently falls back to a
119
+ // default pixel format — breaking non-AR stitching. vision-camera does
120
+ // NOT expose a format's supported pixel formats to JS (no
121
+ // `pixelFormats` field; `FormatFilter` has no pixel-format key), so we
122
+ // can't select an 8-bit format by inspection. Empirically the device's
123
+ // MAX 4:3 video format is 8-bit (420v/420f) on the iPhone 16 Pro, and
124
+ // Android formats are near-universally 8-bit YUV_420_888, so `'max'` is
125
+ // the robust choice: a sharp preview on a frame-processor-compatible
126
+ // pipeline. Trade-off: the max format tends to run at 30 fps (fine for
127
+ // hold-to-pan) and feeds full-res frames to the non-AR gate — if that
128
+ // ever shows up as dropped frames we can downscale for the gate
129
+ // natively while keeping full-res keyframes. Aspect stays the
130
+ // top-priority filter, so 4:3 WYSIWYG parity holds on every device.
131
+ //
132
+ // Still resolution is capped at ~12 MP. The max-video 4:3 format pairs
133
+ // with a 24 MP photo (5712×4284) on the iPhone 16 Pro by default — 2×
134
+ // the file size + per-capture memory for no benefit on the panorama
135
+ // path (which uses the VIDEO stream, not takePhoto). `photoResolution`
136
+ // is the LOWEST-priority filter, so it only breaks ties between equal
137
+ // max-video formats (e.g. the 12 MP-photo vs 24 MP-photo variants that
138
+ // share the same 4032×3024 video) — it never trades preview/stitch
139
+ // sharpness for a smaller still. 4032×3024 = 12 MP at 4:3; nearest-
140
+ // match keeps stills near there on any device.
141
+ const format = (0, react_native_vision_camera_1.useCameraFormat)(device ?? undefined, [
142
+ { photoAspectRatio: 4 / 3 },
143
+ { videoAspectRatio: 4 / 3 },
144
+ { videoResolution: 'max' },
145
+ { photoResolution: { width: 4032, height: 3024 } },
146
+ ]);
147
+ // Measured size of our container, so we can size the <Camera> view to
148
+ // the largest box of the capture's aspect ratio that fits inside it
149
+ // (the rest becomes the black letterbox). We deliberately size the
150
+ // VIEW rather than relying on vision-camera's `resizeMode` alone:
151
+ // resizeMode maps to PreviewView.ScaleType on Android, which several
152
+ // devices ignore under the default SurfaceView compositor — so the
153
+ // preview kept filling the screen. When the view's own aspect ratio
154
+ // equals the feed's, there is nothing left to crop on any platform.
155
+ const [size, setSize] = (0, react_1.useState)(null);
156
+ const onRootLayout = (0, react_1.useCallback)((e) => {
157
+ const { width, height } = e.nativeEvent.layout;
158
+ setSize((prev) => prev && prev.w === width && prev.h === height
159
+ ? prev
160
+ : { w: width, h: height });
161
+ }, []);
95
162
  if (!device) {
96
163
  return (react_1.default.createElement(react_native_1.View, { style: [styles.placeholder, style], accessibilityLabel: "Camera initialising" },
97
164
  react_1.default.createElement(react_native_1.Text, { style: styles.placeholderText }, "Initialising camera\u2026")));
98
165
  }
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 } : {}),
166
+ // Capture aspect ratio (W÷H) in the sensor's native landscape
167
+ // orientation (so > 1). Falls back to 4:3 until the format resolves.
168
+ const sensorAspect = format && format.photoWidth > 0 && format.photoHeight > 0
169
+ ? format.photoWidth / format.photoHeight
170
+ : 4 / 3;
171
+ // With outputOrientation="device", a portrait device displays the
172
+ // scene rotated, so the on-screen content aspect is the inverse of
173
+ // the landscape sensor aspect. Detect portrait from the measured
174
+ // container — robust across devices, split-screen and rotation.
175
+ const isPortrait = size != null ? size.h >= size.w : true;
176
+ const contentAspect = isPortrait ? 1 / sensorAspect : sensorAspect;
177
+ // Largest box of `contentAspect` that fits the container, centred by
178
+ // styles.root. The remaining area is the black letterbox. Before the
179
+ // first onLayout we fill the container so the camera session mounts
180
+ // immediately; the exact box snaps in ~1 frame later.
181
+ let cameraStyle;
182
+ if (size == null || size.w === 0 || size.h === 0) {
183
+ cameraStyle = react_native_1.StyleSheet.absoluteFillObject;
184
+ }
185
+ else {
186
+ const heightIfFullWidth = size.w / contentAspect;
187
+ cameraStyle =
188
+ heightIfFullWidth <= size.h
189
+ ? { width: size.w, height: heightIfFullWidth }
190
+ : { width: size.h * contentAspect, height: size.h };
191
+ }
192
+ return (react_1.default.createElement(react_native_1.View, { style: [styles.root, style], onLayout: onRootLayout },
193
+ react_1.default.createElement(react_native_vision_camera_1.Camera, { ref: innerRef,
194
+ // Sized to the letterboxed box (capture aspect ratio) so the
195
+ // preview never crops; styles.root centres it and paints the
196
+ // surrounding bars black. See the cameraStyle computation above.
197
+ style: cameraStyle, device: device, isActive: isActive, photo: true, video: video,
198
+ // Pin preview + photo to the same 4:3 format (WYSIWYG capture).
199
+ format: format, ...(zoom != null ? { zoom } : {}),
101
200
  // Bake the device orientation into the captured pixels.
102
201
  // Without this, vision-camera writes the file in the camera
103
202
  // sensor's native landscape and relies on EXIF metadata to
@@ -107,7 +206,26 @@ exports.CameraView = (0, react_1.forwardRef)(function CameraView({ device, flash
107
206
  // `outputOrientation="device"` rotates the pixels to match
108
207
  // how the user is holding the phone, so the saved JPEG is
109
208
  // "what you see is what was taken".
110
- outputOrientation: "device", torch: flash === 'on' ? 'on' : 'off', onError: handleVcError, ...cameraProps }),
209
+ outputOrientation: "device",
210
+ // Show the full camera FOV — no cropping. 'contain' maps to
211
+ // AVLayerVideoGravity.resizeAspect on iOS and the equivalent
212
+ // on Android, letterboxing the preview to the sensor's exact
213
+ // aspect ratio. Without this the default 'cover' crops
214
+ // ~19% off each horizontal edge in portrait mode (4:3 sensor
215
+ // in a 9:21 viewport), so the stitcher receives frames the
216
+ // user never saw. Black bars fill the remainder; backgroundColor
217
+ // on styles.root ensures they are always black.
218
+ resizeMode: "contain",
219
+ // Android: force TextureView rendering so that FIT_CENTER
220
+ // (the Android equivalent of resizeMode="contain") actually
221
+ // produces visible letterboxing. The default SurfaceView mode
222
+ // composes at the hardware layer below the View hierarchy and
223
+ // on many devices ignores FIT_CENTER, filling the full surface
224
+ // instead. TextureView is part of the regular View hierarchy
225
+ // so the matrix transform for FIT_CENTER works correctly —
226
+ // the bars outside the letterboxed area are transparent,
227
+ // revealing the parent's black backgroundColor.
228
+ androidPreviewViewType: "texture-view", torch: flash === 'on' ? 'on' : 'off', onError: handleVcError, ...cameraProps }),
111
229
  guidance ? (react_1.default.createElement(react_native_1.View, { style: styles.guidance, pointerEvents: "none", accessible: true, accessibilityRole: "text" },
112
230
  react_1.default.createElement(react_native_1.Text, { style: styles.guidanceText, numberOfLines: 2 }, guidance))) : null));
113
231
  });
@@ -115,6 +233,16 @@ const styles = react_native_1.StyleSheet.create({
115
233
  root: {
116
234
  flex: 1,
117
235
  overflow: 'hidden',
236
+ // Centre the letterboxed <Camera> box so the black bars are
237
+ // symmetric on both sides (top/bottom in portrait, left/right in
238
+ // landscape).
239
+ alignItems: 'center',
240
+ justifyContent: 'center',
241
+ // Black bars when the camera's aspect ratio doesn't fill the
242
+ // container (e.g. 4:3 sensor in a 9:21 portrait viewport). Without
243
+ // this the bars are transparent, revealing whatever is behind the
244
+ // component.
245
+ backgroundColor: '#000',
118
246
  },
119
247
  placeholder: {
120
248
  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.2",
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,77 @@ 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
+ //
167
+ // Resolution preference matters too: filtering on aspect ALONE lets
168
+ // vision-camera settle on whatever 4:3 format sorts first — observed as
169
+ // a 192×144 VIDEO stream on the iPhone 16 Pro (the photo still uses the
170
+ // format's full-res photo dims, so you'd get a sharp capture behind a
171
+ // mush preview). So we also request the highest video resolution.
172
+ //
173
+ // Why `'max'` and not a bounded target like 1920×1440? We tried the
174
+ // bounded target and it FAILED on the iPhone 16 Pro: the nearest
175
+ // 1920×1440 format is a 10-bit format (pixel formats x420 / x422 only —
176
+ // and it is NOT flagged HDR, so the `videoHdr` filter can't dodge it).
177
+ // The frame processor + the stitcher's CV pipeline need 8-bit
178
+ // `420v`/`420f`, so vision-camera raises
179
+ // `device/pixel-format-not-supported` and silently falls back to a
180
+ // default pixel format — breaking non-AR stitching. vision-camera does
181
+ // NOT expose a format's supported pixel formats to JS (no
182
+ // `pixelFormats` field; `FormatFilter` has no pixel-format key), so we
183
+ // can't select an 8-bit format by inspection. Empirically the device's
184
+ // MAX 4:3 video format is 8-bit (420v/420f) on the iPhone 16 Pro, and
185
+ // Android formats are near-universally 8-bit YUV_420_888, so `'max'` is
186
+ // the robust choice: a sharp preview on a frame-processor-compatible
187
+ // pipeline. Trade-off: the max format tends to run at 30 fps (fine for
188
+ // hold-to-pan) and feeds full-res frames to the non-AR gate — if that
189
+ // ever shows up as dropped frames we can downscale for the gate
190
+ // natively while keeping full-res keyframes. Aspect stays the
191
+ // top-priority filter, so 4:3 WYSIWYG parity holds on every device.
192
+ //
193
+ // Still resolution is capped at ~12 MP. The max-video 4:3 format pairs
194
+ // with a 24 MP photo (5712×4284) on the iPhone 16 Pro by default — 2×
195
+ // the file size + per-capture memory for no benefit on the panorama
196
+ // path (which uses the VIDEO stream, not takePhoto). `photoResolution`
197
+ // is the LOWEST-priority filter, so it only breaks ties between equal
198
+ // max-video formats (e.g. the 12 MP-photo vs 24 MP-photo variants that
199
+ // share the same 4032×3024 video) — it never trades preview/stitch
200
+ // sharpness for a smaller still. 4032×3024 = 12 MP at 4:3; nearest-
201
+ // match keeps stills near there on any device.
202
+ const format = useCameraFormat(device ?? undefined, [
203
+ { photoAspectRatio: 4 / 3 },
204
+ { videoAspectRatio: 4 / 3 },
205
+ { videoResolution: 'max' },
206
+ { photoResolution: { width: 4032, height: 3024 } },
207
+ ]);
208
+
209
+ // Measured size of our container, so we can size the <Camera> view to
210
+ // the largest box of the capture's aspect ratio that fits inside it
211
+ // (the rest becomes the black letterbox). We deliberately size the
212
+ // VIEW rather than relying on vision-camera's `resizeMode` alone:
213
+ // resizeMode maps to PreviewView.ScaleType on Android, which several
214
+ // devices ignore under the default SurfaceView compositor — so the
215
+ // preview kept filling the screen. When the view's own aspect ratio
216
+ // equals the feed's, there is nothing left to crop on any platform.
217
+ const [size, setSize] = useState<{ w: number; h: number } | null>(null);
218
+ const onRootLayout = useCallback((e: LayoutChangeEvent) => {
219
+ const { width, height } = e.nativeEvent.layout;
220
+ setSize((prev) =>
221
+ prev && prev.w === width && prev.h === height
222
+ ? prev
223
+ : { w: width, h: height },
224
+ );
225
+ }, []);
226
+
143
227
  if (!device) {
144
228
  return (
145
229
  <View style={[styles.placeholder, style]} accessibilityLabel="Camera initialising">
@@ -148,15 +232,49 @@ export const CameraView = forwardRef<Camera | null, CameraViewProps>(function Ca
148
232
  );
149
233
  }
150
234
 
235
+ // Capture aspect ratio (W÷H) in the sensor's native landscape
236
+ // orientation (so > 1). Falls back to 4:3 until the format resolves.
237
+ const sensorAspect =
238
+ format && format.photoWidth > 0 && format.photoHeight > 0
239
+ ? format.photoWidth / format.photoHeight
240
+ : 4 / 3;
241
+
242
+ // With outputOrientation="device", a portrait device displays the
243
+ // scene rotated, so the on-screen content aspect is the inverse of
244
+ // the landscape sensor aspect. Detect portrait from the measured
245
+ // container — robust across devices, split-screen and rotation.
246
+ const isPortrait = size != null ? size.h >= size.w : true;
247
+ const contentAspect = isPortrait ? 1 / sensorAspect : sensorAspect;
248
+
249
+ // Largest box of `contentAspect` that fits the container, centred by
250
+ // styles.root. The remaining area is the black letterbox. Before the
251
+ // first onLayout we fill the container so the camera session mounts
252
+ // immediately; the exact box snaps in ~1 frame later.
253
+ let cameraStyle: ViewStyle;
254
+ if (size == null || size.w === 0 || size.h === 0) {
255
+ cameraStyle = StyleSheet.absoluteFillObject;
256
+ } else {
257
+ const heightIfFullWidth = size.w / contentAspect;
258
+ cameraStyle =
259
+ heightIfFullWidth <= size.h
260
+ ? { width: size.w, height: heightIfFullWidth }
261
+ : { width: size.h * contentAspect, height: size.h };
262
+ }
263
+
151
264
  return (
152
- <View style={[styles.root, style]}>
265
+ <View style={[styles.root, style]} onLayout={onRootLayout}>
153
266
  <Camera
154
267
  ref={innerRef}
155
- style={StyleSheet.absoluteFill}
268
+ // Sized to the letterboxed box (capture aspect ratio) so the
269
+ // preview never crops; styles.root centres it and paints the
270
+ // surrounding bars black. See the cameraStyle computation above.
271
+ style={cameraStyle}
156
272
  device={device}
157
273
  isActive={isActive}
158
274
  photo
159
275
  video={video}
276
+ // Pin preview + photo to the same 4:3 format (WYSIWYG capture).
277
+ format={format}
160
278
  // v0.13.2 — multi-cam lens switch via zoom (undefined = default).
161
279
  {...(zoom != null ? { zoom } : {})}
162
280
  // Bake the device orientation into the captured pixels.
@@ -169,6 +287,25 @@ export const CameraView = forwardRef<Camera | null, CameraViewProps>(function Ca
169
287
  // how the user is holding the phone, so the saved JPEG is
170
288
  // "what you see is what was taken".
171
289
  outputOrientation="device"
290
+ // Show the full camera FOV — no cropping. 'contain' maps to
291
+ // AVLayerVideoGravity.resizeAspect on iOS and the equivalent
292
+ // on Android, letterboxing the preview to the sensor's exact
293
+ // aspect ratio. Without this the default 'cover' crops
294
+ // ~19% off each horizontal edge in portrait mode (4:3 sensor
295
+ // in a 9:21 viewport), so the stitcher receives frames the
296
+ // user never saw. Black bars fill the remainder; backgroundColor
297
+ // on styles.root ensures they are always black.
298
+ resizeMode="contain"
299
+ // Android: force TextureView rendering so that FIT_CENTER
300
+ // (the Android equivalent of resizeMode="contain") actually
301
+ // produces visible letterboxing. The default SurfaceView mode
302
+ // composes at the hardware layer below the View hierarchy and
303
+ // on many devices ignores FIT_CENTER, filling the full surface
304
+ // instead. TextureView is part of the regular View hierarchy
305
+ // so the matrix transform for FIT_CENTER works correctly —
306
+ // the bars outside the letterboxed area are transparent,
307
+ // revealing the parent's black backgroundColor.
308
+ androidPreviewViewType="texture-view"
172
309
  torch={flash === 'on' ? 'on' : 'off'}
173
310
  onError={handleVcError}
174
311
  {...cameraProps}
@@ -189,6 +326,16 @@ const styles = StyleSheet.create({
189
326
  root: {
190
327
  flex: 1,
191
328
  overflow: 'hidden',
329
+ // Centre the letterboxed <Camera> box so the black bars are
330
+ // symmetric on both sides (top/bottom in portrait, left/right in
331
+ // landscape).
332
+ alignItems: 'center',
333
+ justifyContent: 'center',
334
+ // Black bars when the camera's aspect ratio doesn't fill the
335
+ // container (e.g. 4:3 sensor in a 9:21 portrait viewport). Without
336
+ // this the bars are transparent, revealing whatever is behind the
337
+ // component.
338
+ backgroundColor: '#000',
192
339
  },
193
340
  placeholder: {
194
341
  flex: 1,