Bias the 0.5x lens, since its feed has no frame pipeline

The ultra-wide preview is a bare Camera2 surface: it never goes through
Skia, so the software EV gain that carries exposure on the 1x preview had
nothing to act on and quick EV / the LIGHT-EV slider did nothing at 0.5x.
The lens itself now takes the bias, as CONTROL_AE_EXPOSURE_COMPENSATION on
every preview request (session builder, metering rebuilds, and a setEv call
the viewfinder pushes whenever the value changes).

The still is left alone: capture() pins the compensation back to 0 and
waits on an AE precapture trigger, so the file stays unbiased and the export
applies its software gain exactly once, as it does for the 1x path. The
hardware range clamps the bias where the lens offers less than the app's
+/-3 EV.
This commit is contained in:
2026-09-16 07:41:38 +07:00
parent 39487ffd67
commit 78630d8a2d
3 changed files with 77 additions and 0 deletions
@@ -31,6 +31,7 @@ import java.io.File
import java.io.FileOutputStream
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.roundToInt
private const val TAG = "RecipesCamUltraWide"
@@ -147,6 +148,18 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
// CONTROL_AF_REGIONS / CONTROL_AE_REGIONS.
private var activeArray: Rect? = null
private var sensorOrientation = 90
// Hardware AE bias for the live session. The 0.5x feed is a plain Camera2
// surface with no Skia frame pipeline behind it, so the software EV gain the
// 1x preview runs has nothing to draw on here — the lens itself has to be
// biased. Preview only: capture() pins the bias back to 0 and waits for AE to
// settle there, so the file comes out unbiased and the export applies its
// software gain exactly once, as it does for the 1x path.
private var aeStops = 0.0
private var evRange: IntRange = 0..0
private var evStep = 1.0 / 6.0
// Region of the last tap-to-focus, so an EV change can re-issue the repeating
// request without dropping the lock.
private var lockedRegion: Rect? = null
private var busy = false
private var pending: Promise? = null
// Held by closeAsync() until the HAL has really let go of the lens; see
@@ -386,6 +399,12 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
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)
cs.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE)?.let {
evRange = it.lower..it.upper
}
cs.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_STEP)?.toDouble()?.let {
if (it > 0) evStep = it
}
if (ps == null || fs == null) {
Log.w(TAG, "ultra-wide $id has no preview/jpeg size")
return
@@ -432,7 +451,12 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
}, camHandler)
}
/** The stored EV in the units CONTROL_AE_EXPOSURE_COMPENSATION wants. */
private fun aeCompensation(): Int =
(aeStops / evStep).roundToInt().coerceIn(evRange.first, evRange.last)
private fun createSession(dev: CameraDevice, gen: Int) {
lockedRegion = null
val p = previewSize ?: return
val f = photoSize ?: return
val st = texture.surfaceTexture ?: return
@@ -447,6 +471,7 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
val builder = dev.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
builder.addTarget(surface)
builder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
builder.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, aeCompensation())
try {
dev.createCaptureSession(
listOf(surface, ir.surface),
@@ -496,6 +521,16 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
req.addTarget(ir.surface)
req.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
req.set(CaptureRequest.JPEG_ORIENTATION, sensorOrientation)
// The live preview carries the EV as a hardware AE bias (see setEv); the
// file must not, or the lens bias and the export's software gain would
// stack into a double EV. Pin the bias off and let the precapture trigger
// settle AE on it before the frame is taken — without the trigger the
// first frames can still come off the biased exposure.
req.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, 0)
req.set(
CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER,
CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_START
)
s.capture(req.build(), null, camHandler)
} catch (e: Throwable) {
fail("ERR_CAPTURE", "Capture request failed: ${e.message}")
@@ -578,6 +613,7 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
val ps = previewSurface ?: return null
val b = dev.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
b.addTarget(ps)
b.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, aeCompensation())
if (region == null) {
b.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
return b.build()
@@ -600,6 +636,7 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
fun focusAt(x: Float, y: Float) {
val s = session ?: return
val region = sensorRegion(x, y) ?: return
lockedRegion = region
val hold = meter(region) ?: return
val trigger = meter(region, trigger = true) ?: return
try {
@@ -610,9 +647,28 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
}
}
/**
* Hardware AE bias, in EV stops, for the live preview. The still capture is
* unaffected (see aeStops) — this is a preview-only control.
*/
fun setEv(stops: Double) {
camHandler.post {
if (stops == aeStops) return@post
aeStops = stops
val s = session ?: return@post
val req = meter(lockedRegion) ?: return@post
try {
s.setRepeatingRequest(req, null, camHandler)
} catch (e: Throwable) {
Log.w(TAG, "setEv failed: ${e.message}")
}
}
}
/** Back to whole-frame continuous AF/AE (the tap again to unlock gesture). */
fun resetFocus() {
val s = session ?: return
lockedRegion = null
val back = meter(null) ?: return
try {
s.setRepeatingRequest(back, null, camHandler)
@@ -693,6 +749,11 @@ class UltraWideModule : Module() {
UltraWidePreviewView.current()?.resetFocus()
}
// Preview-only AE bias for the 0.5x lens — see UltraWidePreviewView.setEv.
AsyncFunction("setEvAsync") { ev: Double ->
UltraWidePreviewView.current()?.setEv(ev)
}
// 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).
+8
View File
@@ -16,6 +16,14 @@ export interface UltraWideModule {
focusAsync(x: number, y: number): Promise<void>;
/** Back to whole-frame continuous AF/AE. */
resetFocusAsync(): Promise<void>;
/**
* Hardware AE bias, in EV stops, for the live 0.5x preview. The 1x preview
* gets its EV from the Skia matrix gain, but this feed is a bare Camera2
* surface with no frame pipeline behind it, so the lens has to be biased.
* Preview only: the still is captured without the bias, exactly like the 1x
* path, so the export applies the software gain once and the file matches.
*/
setEvAsync(ev: number): Promise<void>;
/**
* Closes the native session and resolves once the HAL has really released the
* back camera slot. Leaving 0.5x must wait for this before switching the 1x