|
|
|
@@ -0,0 +1,712 @@
|
|
|
|
|
package com.locphamtran.recipescamera.ultrawide
|
|
|
|
|
|
|
|
|
|
import android.content.Context
|
|
|
|
|
import android.graphics.ImageFormat
|
|
|
|
|
import android.graphics.Rect
|
|
|
|
|
import android.graphics.SurfaceTexture
|
|
|
|
|
import android.hardware.camera2.CameraCaptureSession
|
|
|
|
|
import android.hardware.camera2.CameraCharacteristics
|
|
|
|
|
import android.hardware.camera2.CameraDevice
|
|
|
|
|
import android.hardware.camera2.CameraManager
|
|
|
|
|
import android.hardware.camera2.CaptureRequest
|
|
|
|
|
import android.hardware.camera2.params.MeteringRectangle
|
|
|
|
|
import android.media.ImageReader
|
|
|
|
|
import android.os.Handler
|
|
|
|
|
import android.os.HandlerThread
|
|
|
|
|
import android.os.Looper
|
|
|
|
|
import android.util.Log
|
|
|
|
|
import android.util.Size
|
|
|
|
|
import android.view.Surface
|
|
|
|
|
import android.view.TextureView
|
|
|
|
|
import android.view.View
|
|
|
|
|
import android.view.ViewGroup
|
|
|
|
|
import android.widget.ImageView
|
|
|
|
|
import android.widget.LinearLayout
|
|
|
|
|
import expo.modules.kotlin.AppContext
|
|
|
|
|
import expo.modules.kotlin.Promise
|
|
|
|
|
import expo.modules.kotlin.modules.Module
|
|
|
|
|
import expo.modules.kotlin.modules.ModuleDefinition
|
|
|
|
|
import expo.modules.kotlin.views.ExpoView
|
|
|
|
|
import java.io.File
|
|
|
|
|
import java.io.FileOutputStream
|
|
|
|
|
import kotlin.math.abs
|
|
|
|
|
import kotlin.math.min
|
|
|
|
|
|
|
|
|
|
private const val TAG = "RecipesCamUltraWide"
|
|
|
|
|
|
|
|
|
|
// Camera2 open is exclusive: while the main (CameraX) session still holds the
|
|
|
|
|
// HAL the ultra-wide open fails with ERROR_CAMERA_IN_USE, and the release takes
|
|
|
|
|
// a few hundred ms after the preview is switched off — so retry instead of
|
|
|
|
|
// surfacing an error to JS.
|
|
|
|
|
private const val OPEN_RETRY_MS = 80L
|
|
|
|
|
private const val OPEN_MAX_ATTEMPTS = 25
|
|
|
|
|
private const val CAPTURE_TIMEOUT_MS = 6000L
|
|
|
|
|
// Ceiling for closeAsync(): the platform is supposed to report the device gone,
|
|
|
|
|
// but a release that is never signalled must not strand the caller.
|
|
|
|
|
private const val RELEASE_MAX_MS = 300L
|
|
|
|
|
|
|
|
|
|
// Tap-to-focus metering window, as a fraction of a side of the sensor's active
|
|
|
|
|
// array. 16% of the frame is roughly what a finger covers when it taps.
|
|
|
|
|
private const val METER_FRACTION = 0.08f
|
|
|
|
|
|
|
|
|
|
private fun cameraManager(ctx: Context) =
|
|
|
|
|
ctx.getSystemService(Context.CAMERA_SERVICE) as CameraManager
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The Xiaomi 12S Ultra hides its ultra-wide (and tele) logical cameras from
|
|
|
|
|
* `cameraIdList` — CameraX therefore reports a single back device with
|
|
|
|
|
* minZoomRatio 1.0 and no 0.5x. Camera2 can still open the hidden id directly,
|
|
|
|
|
* so detect it as "the widest back lens that is NOT in cameraIdList": back
|
|
|
|
|
* facing, smallest LENS_INFO_AVAILABLE_FOCAL_LENGTHS.
|
|
|
|
|
*
|
|
|
|
|
* Returns "" when the device has no such camera (single-lens / front-only).
|
|
|
|
|
*/
|
|
|
|
|
internal fun findUltraWideId(ctx: Context): String {
|
|
|
|
|
val cm = cameraManager(ctx)
|
|
|
|
|
val exposed = cm.cameraIdList.toHashSet()
|
|
|
|
|
var best = ""
|
|
|
|
|
var bestFocal = Float.MAX_VALUE
|
|
|
|
|
// Ids are sparse on these devices (0/1 exposed, 2/3/6/8/9 hidden) but the
|
|
|
|
|
// space is not bounded by the API — 16 is well above any shipped layout.
|
|
|
|
|
for (id in 0 until 16) {
|
|
|
|
|
val key = id.toString()
|
|
|
|
|
if (exposed.contains(key)) continue
|
|
|
|
|
val cs = try {
|
|
|
|
|
cm.getCameraCharacteristics(key)
|
|
|
|
|
} catch (e: Throwable) {
|
|
|
|
|
continue // no such camera id
|
|
|
|
|
}
|
|
|
|
|
if (cs.get(CameraCharacteristics.LENS_FACING) != CameraCharacteristics.LENS_FACING_BACK) continue
|
|
|
|
|
val focals = cs.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS) ?: continue
|
|
|
|
|
val f = focals.minOrNull() ?: continue
|
|
|
|
|
if (f > 0f && f < bestFocal) {
|
|
|
|
|
bestFocal = f
|
|
|
|
|
best = key
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return best
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Largest 4:3 size no bigger than [maxArea]; any size if the camera has no 4:3. */
|
|
|
|
|
private fun pick4x3(sizes: Array<Size>?, maxArea: Int): Size? {
|
|
|
|
|
if (sizes == null || sizes.isEmpty()) return null
|
|
|
|
|
var best: Size? = null
|
|
|
|
|
var bestArea = -1
|
|
|
|
|
for (s in sizes) {
|
|
|
|
|
if (s.width <= 0 || s.height <= 0) continue
|
|
|
|
|
if (abs(s.width.toDouble() / s.height - 4.0 / 3.0) > 0.02) continue
|
|
|
|
|
val area = s.width * s.height
|
|
|
|
|
if (area > maxArea || area <= bestArea) continue
|
|
|
|
|
bestArea = area
|
|
|
|
|
best = s
|
|
|
|
|
}
|
|
|
|
|
return best ?: sizes.maxByOrNull { it.width * it.height }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Live ultra-wide feed. A TextureView in a Camera2 session: the frames arrive
|
|
|
|
|
* as a landscape 4:3 buffer and are cover-scaled + rotated onto the portrait
|
|
|
|
|
* container by the view transform (no GL, no JS frame hop).
|
|
|
|
|
*/
|
|
|
|
|
class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
|
|
|
companion object {
|
|
|
|
|
@Volatile private var live: UltraWidePreviewView? = null
|
|
|
|
|
fun current(): UltraWidePreviewView? = live
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private val texture = TextureView(context)
|
|
|
|
|
// Freezes the outgoing frame instead of letting it vanish with the session —
|
|
|
|
|
// see holdFrame().
|
|
|
|
|
private val holdView = ImageView(context)
|
|
|
|
|
|
|
|
|
|
// One thread owns every camera callback and the JPEG write — the main thread
|
|
|
|
|
// only ever sets props.
|
|
|
|
|
private var camThread: HandlerThread? = null
|
|
|
|
|
private val camHandler: Handler
|
|
|
|
|
get() {
|
|
|
|
|
val t = camThread ?: HandlerThread("uw-camera").also { it.start(); camThread = it }
|
|
|
|
|
return Handler(t.looper)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var active = false
|
|
|
|
|
// Handoff cover — see setCover().
|
|
|
|
|
private var cover = false
|
|
|
|
|
// Bumped on every activate/deactivate: a retry or capture callback carrying a
|
|
|
|
|
// stale generation must not touch the current session.
|
|
|
|
|
private var generation = 0
|
|
|
|
|
private var cameraId = ""
|
|
|
|
|
private var device: CameraDevice? = null
|
|
|
|
|
private var session: CameraCaptureSession? = null
|
|
|
|
|
private var reader: ImageReader? = null
|
|
|
|
|
private var previewSize: Size? = null
|
|
|
|
|
private var photoSize: Size? = null
|
|
|
|
|
// Preview stream of the live session, kept so a tap can re-issue the request
|
|
|
|
|
// with a metering region.
|
|
|
|
|
private var previewSurface: Surface? = null
|
|
|
|
|
// Active pixel array of the sensor: the coordinate space Camera2 wants for
|
|
|
|
|
// CONTROL_AF_REGIONS / CONTROL_AE_REGIONS.
|
|
|
|
|
private var activeArray: Rect? = null
|
|
|
|
|
private var sensorOrientation = 90
|
|
|
|
|
private var busy = false
|
|
|
|
|
private var pending: Promise? = null
|
|
|
|
|
// Held by closeAsync() until the HAL has really let go of the lens; see
|
|
|
|
|
// release().
|
|
|
|
|
private var closedPromise: Promise? = null
|
|
|
|
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
|
|
|
// Frames drawn by the current session: leaving 0.5x while the lens is still
|
|
|
|
|
// opening must not hold a never-painted (black) frame over the viewfinder.
|
|
|
|
|
private var frameCount = 0
|
|
|
|
|
|
|
|
|
|
init {
|
|
|
|
|
// The frame is laid out at the buffer's own size and only then turned onto
|
|
|
|
|
// the viewfinder box, so the overflow (if the frame is wider than the box)
|
|
|
|
|
// has to be clipped away.
|
|
|
|
|
clipChildren = true
|
|
|
|
|
val fill = LinearLayout.LayoutParams(
|
|
|
|
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
|
|
|
ViewGroup.LayoutParams.MATCH_PARENT
|
|
|
|
|
)
|
|
|
|
|
addView(texture, fill)
|
|
|
|
|
holdView.visibility = View.GONE
|
|
|
|
|
holdView.scaleType = ImageView.ScaleType.FIT_XY
|
|
|
|
|
addView(holdView, fill)
|
|
|
|
|
live = this
|
|
|
|
|
texture.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
|
|
|
|
|
override fun onSurfaceTextureAvailable(st: SurfaceTexture, width: Int, height: Int) {
|
|
|
|
|
val p = previewSize
|
|
|
|
|
if (p != null) st.setDefaultBufferSize(p.width, p.height)
|
|
|
|
|
if (active) openCamera()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, width: Int, height: Int) {}
|
|
|
|
|
override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean = true
|
|
|
|
|
override fun onSurfaceTextureUpdated(st: SurfaceTexture) {
|
|
|
|
|
frameCount++
|
|
|
|
|
// First frame of a fresh session: only now may the held frame go, see
|
|
|
|
|
// syncViews().
|
|
|
|
|
if (frameCount == 1) syncViews()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
|
|
|
super.onSizeChanged(w, h, oldw, oldh)
|
|
|
|
|
applyPreviewTransform()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ReactViewGroup lays nobody out, and a TextureView stretches its buffer onto
|
|
|
|
|
// whatever box it is given. So the texture is laid out 1:1 with the frame and
|
|
|
|
|
// framed with the plain view properties, which act in the container's space:
|
|
|
|
|
// unlike the setTransform matrix (which works inside the already-stretched
|
|
|
|
|
// texture space) they cannot come out sideways or sheared.
|
|
|
|
|
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
|
|
|
|
val p = previewSize
|
|
|
|
|
if (p == null) {
|
|
|
|
|
texture.layout(0, 0, r - l, b - t)
|
|
|
|
|
holdView.layout(0, 0, r - l, b - t)
|
|
|
|
|
} else {
|
|
|
|
|
texture.layout(0, 0, p.width, p.height)
|
|
|
|
|
holdView.layout(0, 0, p.width, p.height)
|
|
|
|
|
}
|
|
|
|
|
applyPreviewTransform()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Frame the ultra-wide exactly like the main preview: the full-width 3:4 box.
|
|
|
|
|
* The preview stream of this (hidden) ultra-wide id already arrives the right
|
|
|
|
|
* way up — same orientation as its JPEG, merely squeezed into the 4:3 buffer —
|
|
|
|
|
* so there is nothing to rotate: stretching the buffer onto the 3:4 box both
|
|
|
|
|
* un-squeezes it and fills the box, which is what the main preview shows too.
|
|
|
|
|
* Rotation and scale act about the texture's own centre, so aligning the two
|
|
|
|
|
* centres is enough.
|
|
|
|
|
*/
|
|
|
|
|
private fun applyPreviewTransform() {
|
|
|
|
|
val p = previewSize ?: return
|
|
|
|
|
val cw = width.toFloat()
|
|
|
|
|
val ch = height.toFloat()
|
|
|
|
|
if (cw <= 0f || ch <= 0f) return
|
|
|
|
|
val pw = p.width.toFloat()
|
|
|
|
|
val ph = p.height.toFloat()
|
|
|
|
|
// The sizing must happen here, not only in onLayout: openCamera() resolves
|
|
|
|
|
// the preview size *after* the first layout pass, so that pass laid the
|
|
|
|
|
// texture out at the container size and nothing put it back.
|
|
|
|
|
val boxH = min(ch, cw * 4f / 3f)
|
|
|
|
|
for (v in listOf<View>(texture, holdView)) {
|
|
|
|
|
v.layout(0, 0, p.width, p.height)
|
|
|
|
|
v.rotation = 0f
|
|
|
|
|
v.scaleX = cw / pw
|
|
|
|
|
v.scaleY = boxH / ph
|
|
|
|
|
v.translationX = cw / 2f - pw / 2f
|
|
|
|
|
v.translationY = ch / 2f - ph / 2f
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Whether the ultra-wide stays on screen. The two back lenses are mutually
|
|
|
|
|
* exclusive, so leaving 0.5x closes this session and only then can the main
|
|
|
|
|
* one reopen — on screen that is a frozen stale frame, then the reopening
|
|
|
|
|
* session's out-of-focus, shifted first frames. JS keeps this true across
|
|
|
|
|
* that window so the held frame (holdFrame) covers it, and drops it once the
|
|
|
|
|
* main preview is live.
|
|
|
|
|
*
|
|
|
|
|
* Deliberately plain visibility toggles: a GONE TextureView stops being
|
|
|
|
|
* updated, so gating the first frame of a fresh session behind one would
|
|
|
|
|
* leave the view hidden forever (measured: the session opens on camera id 2
|
|
|
|
|
* and nothing is ever drawn).
|
|
|
|
|
*/
|
|
|
|
|
fun setCover(next: Boolean) {
|
|
|
|
|
if (next == cover) return
|
|
|
|
|
cover = next
|
|
|
|
|
syncViews()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun syncViews() {
|
|
|
|
|
texture.visibility = if (active) View.VISIBLE else View.GONE
|
|
|
|
|
// Held frame stays up until the fresh session has painted. Dissolving before
|
|
|
|
|
// that reveals the surface the session is still rebuilding — measured black
|
|
|
|
|
// for the rest of the open on this device, right where the 0.5x -> 1x swap
|
|
|
|
|
// is supposed to look seamless. During a rebind frameCount is 0, so the
|
|
|
|
|
// gate is "this session has drawn something", not "is active".
|
|
|
|
|
// A session the caller handed back through release() is gone: its last
|
|
|
|
|
// frameCount must not read as fresh, or the held frame would drop off a
|
|
|
|
|
// dead (black) texture.
|
|
|
|
|
val fresh = active && device != null && frameCount > 0
|
|
|
|
|
val holding = !fresh && (cover || frameCount == 0) && holdView.drawable != null
|
|
|
|
|
if (holding) {
|
|
|
|
|
holdView.visibility = View.VISIBLE
|
|
|
|
|
} else if (holdView.visibility == View.VISIBLE) {
|
|
|
|
|
// The incoming preview is already settled by now — dissolve rather than
|
|
|
|
|
// cut, so the swap between two different lenses reads as one motion.
|
|
|
|
|
holdView.animate().alpha(0f).setDuration(160).withEndAction {
|
|
|
|
|
holdView.visibility = View.GONE
|
|
|
|
|
holdView.alpha = 1f
|
|
|
|
|
holdView.setImageDrawable(null)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Copy the frame that is on screen into the hold layer. Closing the device
|
|
|
|
|
* abandons the texture's buffer queue: the last frame does NOT stay on screen
|
|
|
|
|
* (measured — the ultra-wide content disappears within one frame of the
|
|
|
|
|
* close), so it has to be copied out first.
|
|
|
|
|
*/
|
|
|
|
|
private fun holdFrame() {
|
|
|
|
|
// No frame drawn by this session yet (left again while the lens was still
|
|
|
|
|
// opening): the texture has nothing worth holding, and copying it out would
|
|
|
|
|
// paste a black frame over the viewfinder. Same once the device is gone —
|
|
|
|
|
// release() already froze that frame, and the dead texture now reads black.
|
|
|
|
|
if (device == null || frameCount == 0) return
|
|
|
|
|
val bmp = try {
|
|
|
|
|
texture.bitmap
|
|
|
|
|
} catch (e: Throwable) {
|
|
|
|
|
null
|
|
|
|
|
}
|
|
|
|
|
if (bmp == null) return
|
|
|
|
|
holdView.animate().cancel()
|
|
|
|
|
holdView.alpha = 1f
|
|
|
|
|
holdView.setImageBitmap(bmp)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun setActive(next: Boolean) {
|
|
|
|
|
if (next == active) return
|
|
|
|
|
active = next
|
|
|
|
|
generation++
|
|
|
|
|
if (next) {
|
|
|
|
|
frameCount = 0
|
|
|
|
|
// openCamera() resolves the sizes itself, so only the surface needs to be
|
|
|
|
|
// ready here — the texture is usually already available, in which case its
|
|
|
|
|
// listener has long since fired (while inactive) and will not fire again.
|
|
|
|
|
val st = texture.surfaceTexture
|
|
|
|
|
if (st != null) {
|
|
|
|
|
val p = previewSize
|
|
|
|
|
if (p != null) st.setDefaultBufferSize(p.width, p.height)
|
|
|
|
|
openCamera()
|
|
|
|
|
}
|
|
|
|
|
// Otherwise the surface listener opens it once the texture is ready.
|
|
|
|
|
} else {
|
|
|
|
|
// Always copy the outgoing frame before the device closes — `setCover`
|
|
|
|
|
// decides whether it is drawn, and the two props may land in either order.
|
|
|
|
|
holdFrame()
|
|
|
|
|
closeAll()
|
|
|
|
|
}
|
|
|
|
|
syncViews()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Hand the lens back: freeze the outgoing frame, close the session and resolve
|
|
|
|
|
* once the platform says the device is gone. The caller re-activates the 1x
|
|
|
|
|
* preview only on that signal, because CameraX opens the main lens the moment
|
|
|
|
|
* it is switched back on and the HAL refuses that open while this one still
|
|
|
|
|
* holds the back camera slot — it is retried on a fixed ~520ms timeout, which
|
|
|
|
|
* is exactly the cost this handshake removes.
|
|
|
|
|
*/
|
|
|
|
|
fun release(promise: Promise) {
|
|
|
|
|
if (device == null && session == null) {
|
|
|
|
|
promise.resolve(null)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
holdFrame()
|
|
|
|
|
closedPromise = promise
|
|
|
|
|
closeAll()
|
|
|
|
|
// frameCount stays put: it is what tells syncViews this lens did paint,
|
|
|
|
|
// so the held frame above keeps covering the main preview until the caller
|
|
|
|
|
// drops it (holding on a never-painted session would freeze that frame).
|
|
|
|
|
syncViews()
|
|
|
|
|
camHandler.postDelayed({ onReleased() }, RELEASE_MAX_MS)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Back to 0.5x while a release() was still in flight: take the lens again. */
|
|
|
|
|
fun reopen() {
|
|
|
|
|
if (!active || device != null) return
|
|
|
|
|
frameCount = 0
|
|
|
|
|
openCamera()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun onReleased() {
|
|
|
|
|
val p = closedPromise ?: return
|
|
|
|
|
closedPromise = null
|
|
|
|
|
mainHandler.post { p.resolve(null) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun openCamera(attempt: Int = 0) {
|
|
|
|
|
if (!active) return
|
|
|
|
|
val ctx = context.applicationContext
|
|
|
|
|
val id = cameraId.ifEmpty { findUltraWideId(ctx) }
|
|
|
|
|
if (id.isEmpty()) {
|
|
|
|
|
Log.w(TAG, "no ultra-wide camera on this device")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
cameraId = id
|
|
|
|
|
val gen = generation
|
|
|
|
|
try {
|
|
|
|
|
val cs = cameraManager(ctx).getCameraCharacteristics(id)
|
|
|
|
|
val map = cs.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
|
|
|
|
|
sensorOrientation = cs.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 90
|
|
|
|
|
val ps = pick4x3(map?.getOutputSizes(SurfaceTexture::class.java), 1920 * 1440)
|
|
|
|
|
val fs = pick4x3(map?.getOutputSizes(ImageFormat.JPEG), Int.MAX_VALUE)
|
|
|
|
|
activeArray = cs.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE)
|
|
|
|
|
if (ps == null || fs == null) {
|
|
|
|
|
Log.w(TAG, "ultra-wide $id has no preview/jpeg size")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (ps != previewSize) {
|
|
|
|
|
previewSize = ps
|
|
|
|
|
texture.surfaceTexture?.setDefaultBufferSize(ps.width, ps.height)
|
|
|
|
|
applyPreviewTransform()
|
|
|
|
|
}
|
|
|
|
|
photoSize = fs
|
|
|
|
|
} catch (e: Throwable) {
|
|
|
|
|
Log.w(TAG, "characteristics($id) failed: ${e.message}")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cameraManager(ctx).openCamera(id, object : CameraDevice.StateCallback() {
|
|
|
|
|
override fun onOpened(dev: CameraDevice) {
|
|
|
|
|
if (gen != generation) {
|
|
|
|
|
dev.close()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
device = dev
|
|
|
|
|
createSession(dev, gen)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onDisconnected(dev: CameraDevice) {
|
|
|
|
|
dev.close()
|
|
|
|
|
if (device === dev) device = null
|
|
|
|
|
if (gen == generation && active) camHandler.postDelayed({ openCamera() }, OPEN_RETRY_MS)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onError(dev: CameraDevice, error: Int) {
|
|
|
|
|
dev.close()
|
|
|
|
|
if (device === dev) device = null
|
|
|
|
|
Log.w(TAG, "openCamera($id) error $error (attempt $attempt)")
|
|
|
|
|
if (gen == generation && active && attempt < OPEN_MAX_ATTEMPTS) {
|
|
|
|
|
camHandler.postDelayed({ openCamera(attempt + 1) }, OPEN_RETRY_MS)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onClosed(dev: CameraDevice) {
|
|
|
|
|
onReleased()
|
|
|
|
|
}
|
|
|
|
|
}, camHandler)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun createSession(dev: CameraDevice, gen: Int) {
|
|
|
|
|
val p = previewSize ?: return
|
|
|
|
|
val f = photoSize ?: return
|
|
|
|
|
val st = texture.surfaceTexture ?: return
|
|
|
|
|
st.setDefaultBufferSize(p.width, p.height)
|
|
|
|
|
val surface = Surface(st)
|
|
|
|
|
previewSurface?.release()
|
|
|
|
|
previewSurface = surface
|
|
|
|
|
val ir = ImageReader.newInstance(f.width, f.height, ImageFormat.JPEG, 2)
|
|
|
|
|
ir.setOnImageAvailableListener({ r -> onJpegAvailable(r) }, camHandler)
|
|
|
|
|
reader = ir
|
|
|
|
|
|
|
|
|
|
val builder = dev.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
|
|
|
|
|
builder.addTarget(surface)
|
|
|
|
|
builder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
|
|
|
|
|
try {
|
|
|
|
|
dev.createCaptureSession(
|
|
|
|
|
listOf(surface, ir.surface),
|
|
|
|
|
object : CameraCaptureSession.StateCallback() {
|
|
|
|
|
override fun onConfigured(s: CameraCaptureSession) {
|
|
|
|
|
if (gen != generation) {
|
|
|
|
|
s.close()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
session = s
|
|
|
|
|
try {
|
|
|
|
|
s.setRepeatingRequest(builder.build(), null, camHandler)
|
|
|
|
|
} catch (e: Throwable) {
|
|
|
|
|
Log.w(TAG, "repeating request failed: ${e.message}")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onConfigureFailed(s: CameraCaptureSession) {
|
|
|
|
|
Log.w(TAG, "session config failed")
|
|
|
|
|
if (gen == generation && active) camHandler.postDelayed({ openCamera() }, OPEN_RETRY_MS)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
camHandler
|
|
|
|
|
)
|
|
|
|
|
} catch (e: Throwable) {
|
|
|
|
|
Log.w(TAG, "createCaptureSession failed: ${e.message}")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun capture(promise: Promise) {
|
|
|
|
|
val dev = device
|
|
|
|
|
val s = session
|
|
|
|
|
val ir = reader
|
|
|
|
|
if (dev == null || s == null || ir == null) {
|
|
|
|
|
promise.reject("ERR_NOT_READY", "Ultra-wide camera is not ready", null)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (busy) {
|
|
|
|
|
promise.reject("ERR_BUSY", "A capture is already in flight", null)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
busy = true
|
|
|
|
|
pending = promise
|
|
|
|
|
val gen = generation
|
|
|
|
|
try {
|
|
|
|
|
val req = dev.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE)
|
|
|
|
|
req.addTarget(ir.surface)
|
|
|
|
|
req.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
|
|
|
|
|
req.set(CaptureRequest.JPEG_ORIENTATION, sensorOrientation)
|
|
|
|
|
s.capture(req.build(), null, camHandler)
|
|
|
|
|
} catch (e: Throwable) {
|
|
|
|
|
fail("ERR_CAPTURE", "Capture request failed: ${e.message}")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
camHandler.postDelayed({
|
|
|
|
|
if (busy && gen == generation) fail("ERR_TIMEOUT", "Ultra-wide capture timed out")
|
|
|
|
|
}, CAPTURE_TIMEOUT_MS)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun onJpegAvailable(r: ImageReader) {
|
|
|
|
|
val image = try {
|
|
|
|
|
r.acquireNextImage()
|
|
|
|
|
} catch (e: Throwable) {
|
|
|
|
|
null
|
|
|
|
|
}
|
|
|
|
|
if (image == null) {
|
|
|
|
|
fail("ERR_IMAGE", "No image from the ultra-wide capture")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
val out = File(context.cacheDir, "uw_${System.currentTimeMillis()}.jpg")
|
|
|
|
|
try {
|
|
|
|
|
val buf = image.planes[0].buffer
|
|
|
|
|
val bytes = ByteArray(buf.remaining())
|
|
|
|
|
buf.get(bytes)
|
|
|
|
|
FileOutputStream(out).use { it.write(bytes) }
|
|
|
|
|
} catch (e: Throwable) {
|
|
|
|
|
fail("ERR_WRITE", "Could not write the ultra-wide photo: ${e.message}")
|
|
|
|
|
return
|
|
|
|
|
} finally {
|
|
|
|
|
image.close()
|
|
|
|
|
}
|
|
|
|
|
val p = pending
|
|
|
|
|
pending = null
|
|
|
|
|
busy = false
|
|
|
|
|
Log.i(TAG, "captured ${out.absolutePath} (${out.length()} bytes)")
|
|
|
|
|
p?.resolve(out.absolutePath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun fail(code: String, message: String) {
|
|
|
|
|
val p = pending
|
|
|
|
|
pending = null
|
|
|
|
|
busy = false
|
|
|
|
|
p?.reject(code, message, null)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Tap-to-focus + spot metering on the native lens. Camera2 wants metering
|
|
|
|
|
* regions in active-array coordinates, so the tap travels back through the
|
|
|
|
|
* preview transform. This lens is a landscape 4000x3000 sensor
|
|
|
|
|
* (SENSOR_ORIENTATION 90) whose stream reaches the portrait box already
|
|
|
|
|
* rotated by the HAL, so the tap's vertical fraction is the sensor's x and
|
|
|
|
|
* its horizontal fraction, flipped, is the sensor's y. Measured on device by
|
|
|
|
|
* metering 25 taps and ranking the settled frame brightness against the
|
|
|
|
|
* scene: this axis order scores +0.72, every other one +0.20 or worse.
|
|
|
|
|
*/
|
|
|
|
|
private fun sensorRegion(x: Float, y: Float): Rect? {
|
|
|
|
|
val arr = activeArray ?: return null
|
|
|
|
|
val cw = width.toFloat()
|
|
|
|
|
val ch = height.toFloat()
|
|
|
|
|
if (cw <= 0f || ch <= 0f) return null
|
|
|
|
|
val boxH = min(ch, cw * 4f / 3f)
|
|
|
|
|
val u = (x / cw).coerceIn(0f, 1f)
|
|
|
|
|
val v = ((y - (ch - boxH) / 2f) / boxH).coerceIn(0f, 1f)
|
|
|
|
|
val px = arr.left + v * arr.width()
|
|
|
|
|
val py = arr.top + (1f - u) * arr.height()
|
|
|
|
|
val hw = arr.width() * METER_FRACTION
|
|
|
|
|
val hh = arr.height() * METER_FRACTION
|
|
|
|
|
return Rect(
|
|
|
|
|
(px - hw).toInt().coerceIn(arr.left, arr.right - 1),
|
|
|
|
|
(py - hh).toInt().coerceIn(arr.top, arr.bottom - 1),
|
|
|
|
|
(px + hw).toInt().coerceIn(arr.left + 1, arr.right),
|
|
|
|
|
(py + hh).toInt().coerceIn(arr.top + 1, arr.bottom)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Preview request that meters [region] only; null = whole-frame continuous. */
|
|
|
|
|
private fun meter(region: Rect?, trigger: Boolean = false): CaptureRequest? {
|
|
|
|
|
val dev = device ?: return null
|
|
|
|
|
val ps = previewSurface ?: return null
|
|
|
|
|
val b = dev.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
|
|
|
|
|
b.addTarget(ps)
|
|
|
|
|
if (region == null) {
|
|
|
|
|
b.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
|
|
|
|
|
return b.build()
|
|
|
|
|
}
|
|
|
|
|
val regions = arrayOf(MeteringRectangle(region, MeteringRectangle.METERING_WEIGHT_MAX))
|
|
|
|
|
// AF_MODE_AUTO holds the lens where the scan leaves it, so the tapped plane
|
|
|
|
|
// stays in focus until the next tap or the unlock. Triggering inside the
|
|
|
|
|
// repeating request itself would restart the scan every frame and never
|
|
|
|
|
// lock, which is why the trigger goes out as one extra frame.
|
|
|
|
|
b.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO)
|
|
|
|
|
b.set(CaptureRequest.CONTROL_AF_REGIONS, regions)
|
|
|
|
|
b.set(CaptureRequest.CONTROL_AE_REGIONS, regions)
|
|
|
|
|
if (trigger) {
|
|
|
|
|
b.set(CaptureRequest.CONTROL_AF_TRIGGER, CaptureRequest.CONTROL_AF_TRIGGER_START)
|
|
|
|
|
b.set(CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER, CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_START)
|
|
|
|
|
}
|
|
|
|
|
return b.build()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun focusAt(x: Float, y: Float) {
|
|
|
|
|
val s = session ?: return
|
|
|
|
|
val region = sensorRegion(x, y) ?: return
|
|
|
|
|
val hold = meter(region) ?: return
|
|
|
|
|
val trigger = meter(region, trigger = true) ?: return
|
|
|
|
|
try {
|
|
|
|
|
s.setRepeatingRequest(hold, null, camHandler)
|
|
|
|
|
s.capture(trigger, null, camHandler)
|
|
|
|
|
} catch (e: Throwable) {
|
|
|
|
|
Log.w(TAG, "focusAt failed: ${e.message}")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Back to whole-frame continuous AF/AE (the tap again to unlock gesture). */
|
|
|
|
|
fun resetFocus() {
|
|
|
|
|
val s = session ?: return
|
|
|
|
|
val back = meter(null) ?: return
|
|
|
|
|
try {
|
|
|
|
|
s.setRepeatingRequest(back, null, camHandler)
|
|
|
|
|
} catch (e: Throwable) {
|
|
|
|
|
Log.w(TAG, "resetFocus failed: ${e.message}")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun closeAll() {
|
|
|
|
|
fail("ERR_CLOSED", "Ultra-wide camera closed")
|
|
|
|
|
try {
|
|
|
|
|
session?.close()
|
|
|
|
|
} catch (e: Throwable) {
|
|
|
|
|
Log.w(TAG, "session close: ${e.message}")
|
|
|
|
|
}
|
|
|
|
|
session = null
|
|
|
|
|
try {
|
|
|
|
|
reader?.close()
|
|
|
|
|
} catch (e: Throwable) {
|
|
|
|
|
Log.w(TAG, "reader close: ${e.message}")
|
|
|
|
|
}
|
|
|
|
|
reader = null
|
|
|
|
|
try {
|
|
|
|
|
device?.close()
|
|
|
|
|
} catch (e: Throwable) {
|
|
|
|
|
Log.w(TAG, "device close: ${e.message}")
|
|
|
|
|
}
|
|
|
|
|
device = null
|
|
|
|
|
try {
|
|
|
|
|
previewSurface?.release()
|
|
|
|
|
} catch (e: Throwable) {
|
|
|
|
|
Log.w(TAG, "surface release: ${e.message}")
|
|
|
|
|
}
|
|
|
|
|
previewSurface = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onDetachedFromWindow() {
|
|
|
|
|
super.onDetachedFromWindow()
|
|
|
|
|
closeAll()
|
|
|
|
|
if (live === this) live = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class UltraWideModule : Module() {
|
|
|
|
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
|
|
|
|
|
|
|
|
override fun definition() = ModuleDefinition {
|
|
|
|
|
Name("RecipesCamUltraWide")
|
|
|
|
|
|
|
|
|
|
AsyncFunction("availableAsync") { ->
|
|
|
|
|
val ctx = appContext.reactContext?.applicationContext ?: return@AsyncFunction ""
|
|
|
|
|
val id = findUltraWideId(ctx)
|
|
|
|
|
id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
View(UltraWidePreviewView::class) {
|
|
|
|
|
Prop("active") { view: UltraWidePreviewView, active: Boolean -> view.setActive(active) }
|
|
|
|
|
Prop("cover") { view: UltraWidePreviewView, cover: Boolean -> view.setCover(cover) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
AsyncFunction("captureAsync") { promise: Promise ->
|
|
|
|
|
val view = UltraWidePreviewView.current()
|
|
|
|
|
if (view == null) {
|
|
|
|
|
promise.reject("ERR_NO_VIEW", "Ultra-wide preview is not mounted", null)
|
|
|
|
|
} else {
|
|
|
|
|
view.capture(promise)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Tap coordinates arrive in dp, like every other React Native touch point;
|
|
|
|
|
// the view transform works in pixels.
|
|
|
|
|
AsyncFunction("focusAsync") { x: Double, y: Double ->
|
|
|
|
|
val density = appContext.reactContext?.resources?.displayMetrics?.density ?: 1f
|
|
|
|
|
UltraWidePreviewView.current()?.focusAt((x * density).toFloat(), (y * density).toFloat())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
AsyncFunction("resetFocusAsync") {
|
|
|
|
|
UltraWidePreviewView.current()?.resetFocus()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolves once the ultra-wide has actually released the back camera slot,
|
|
|
|
|
// so the caller can re-activate the 1x preview without the open being
|
|
|
|
|
// refused (see UltraWidePreviewView.release).
|
|
|
|
|
// Expo dispatches async functions on its own queue, not the main thread,
|
|
|
|
|
// and ViewRootImpl kills the process outright when another thread touches a
|
|
|
|
|
// view — so the calls that reach into the view hierarchy are posted here.
|
|
|
|
|
AsyncFunction("closeAsync") { promise: Promise ->
|
|
|
|
|
val view = UltraWidePreviewView.current()
|
|
|
|
|
if (view == null) promise.resolve(null) else mainHandler.post { view.release(promise) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
AsyncFunction("reopenAsync") {
|
|
|
|
|
val view = UltraWidePreviewView.current()
|
|
|
|
|
if (view != null) mainHandler.post { view.reopen() }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|