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 +56 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +116 -7
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +49 -0
- package/dist/camera/Camera.js +1 -1
- package/dist/camera/CameraView.js +131 -3
- package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +82 -7
- package/package.json +1 -1
- package/src/camera/Camera.tsx +1 -1
- package/src/camera/CameraView.tsx +151 -4
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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.
|
package/dist/camera/Camera.js
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
|
75
|
-
//
|
|
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
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
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.
|
|
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",
|
package/src/camera/Camera.tsx
CHANGED
|
@@ -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, {
|
|
23
|
-
|
|
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
|
-
|
|
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,
|