diff --git a/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt b/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt index 0276e75..d421eb7 100644 --- a/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt +++ b/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt @@ -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). diff --git a/modules/recipescam-ultrawide/index.ts b/modules/recipescam-ultrawide/index.ts index 09ab565..aa6e149 100644 --- a/modules/recipescam-ultrawide/index.ts +++ b/modules/recipescam-ultrawide/index.ts @@ -16,6 +16,14 @@ export interface UltraWideModule { focusAsync(x: number, y: number): Promise; /** Back to whole-frame continuous AF/AE. */ resetFocusAsync(): Promise; + /** + * 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; /** * 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 diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 67f2d04..e86f877 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -559,6 +559,14 @@ const Viewfinder = forwardRef(function Viewfi const [ultraWideOn, setUltraWideOn] = useState(false); const ultraWideOnRef = useRef(false); ultraWideOnRef.current = ultraWideOn; + // The 0.5x feed is a bare Camera2 surface — the Skia matrix gain that carries + // EV on the 1x preview never touches it, so the lens gets the same EV as a + // hardware AE bias instead. The still is captured unbiased (see the module), + // so the export applies its software gain once and the file matches. + useEffect(() => { + if (!ultraWideOn) return; + UltraWide.setEvAsync(evApplied).catch(() => {}); + }, [ultraWideOn, evApplied]); useEffect(() => { let alive = true; UltraWide.availableAsync()