Ship LITE and PRO, and let CLARITY and LIGHT GLOW reach the library
Both flavors build from one JS tree: `lite` hands out the free apk (com.locphamtran.recipescamera), `pro` sells as-is (same id + .pro, label "RecipesCam Pro"). Release is signed from android/keystore.properties, both files gitignored - lose either and no later build can install over this one. The tier reaches JS through src/provariant.ts, which tools/set-variant.mjs rewrites before each build (`npm run apk:lite` / `apk:pro`); the gradle flavor alone would ship a Pro apk that thinks it is Lite. The dev scaffolding goes: the three src/dev probes and their App effects, the EXPO_PUBLIC_CAPTURE_MODE [CAPTURE] timing log, and the -PdevtestSuffix side-by-side install hook. 'balanced' stays the shipped capture mode, which is what those probes measured on the 12S Ultra. Looking at the picture: WB now converts kelvin through the Planckian locus into a luma-normalised gain (unity at 5500K, symmetric tint) instead of the old one-sided push; LEITZ STREETLIFE splits into CLASSIC and VIVID; selecting a recipe loads every knob it carries over the defaults and stays editable, and RESET hands the panel back to the sim's own values. The in-app library picker, the ultra-wide native module and recipe share/import land here too. CLARITY and LIGHT GLOW existed only on the camera worklet and the export engine, so a photo opened from the library ignored both. They now run in all three library preview branches (libPhoto), and the preview clips them to the drawn image - the export engine always clipped its bloom to the canvas, the preview drew it over the whole rect, so the bloom bled past the photo onto the black letterbox and over the frame's mat.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'expo-module-gradle-plugin'
|
||||
}
|
||||
|
||||
group = 'com.locphamtran.recipescamera'
|
||||
version = '0.1.0'
|
||||
|
||||
android {
|
||||
namespace "com.locphamtran.recipescamera.ultrawide"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
}
|
||||
}
|
||||
+712
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user