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 +33 -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 +93 -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 +113 -4
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
|
-
|
|
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,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
|
-
|
|
100
|
-
|
|
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",
|
|
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
|
-
|
|
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.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",
|
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,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
|
-
|
|
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,
|