Fire the LED on the 0.5x still

0.5x is a bare Camera2 session, so CameraX never saw the flash setting and the ultra-wide still came off unlit. The native module now reads FLASH_INFO_AVAILABLE off the lens, maps off/auto/on to CONTROL_AE_MODE plus FLASH_MODE_SINGLE, and starts an AE precapture so the HAL can meter the flash before the frame. The viewfinder hands its flashMode down beside the shutter, with a Zap control that only appears where a unit exists.
This commit is contained in:
2026-09-16 18:11:17 +07:00
parent 12d58ff810
commit c40b5565d3
5 changed files with 242 additions and 99 deletions
@@ -9,6 +9,8 @@ import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CaptureRequest
import android.hardware.camera2.CaptureResult
import android.hardware.camera2.TotalCaptureResult
import android.hardware.camera2.params.MeteringRectangle
import android.media.ImageReader
import android.os.Handler
@@ -160,6 +162,11 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
// 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
// Whether this lens may drive the flash unit at all. The LED physically
// belongs to the back flash unit the main camera owns, and the spec rejects a
// flash request on a camera whose FLASH_INFO_AVAILABLE is false, so the still
// request clamps to OFF rather than failing the shot on such a lens.
private var flashAvailable = false
private var busy = false
private var pending: Promise? = null
// Held by closeAsync() until the HAL has really let go of the lens; see
@@ -399,6 +406,8 @@ 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)
flashAvailable = cs.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
Log.i(TAG, "ultra-wide $id flash=$flashAvailable")
cs.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE)?.let {
evRange = it.lower..it.upper
}
@@ -501,7 +510,29 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
}
}
fun capture(promise: Promise) {
/**
* The still request's flash settings for the JS 'off' | 'auto' | 'on'.
* Camera2 has no FLASH_MODE_AUTO: the auto behaviour lives in
* CONTROL_AE_MODE, where the HAL owns the unit and fires it only when AE
* comes back with CONTROL_AE_STATE_FLASH_REQUIRED; 'on' is the same knob with
* the firing forced. Both ride next to FLASH_MODE_SINGLE so a HAL that
* ignores the AE override still fires on this one request; the spec has the
* AE flash modes win over FLASH_MODE, so the pair cannot double-fire.
* A lens with no unit reports FLASH_INFO_AVAILABLE false, and the flash
* controls only exist where it is true, so every mode collapses to OFF there.
*/
private fun flashAeMode(mode: String): Int = when {
!flashAvailable -> CaptureRequest.CONTROL_AE_MODE_ON
mode == "on" -> CaptureRequest.CONTROL_AE_MODE_ON_ALWAYS_FLASH
mode == "auto" -> CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH
else -> CaptureRequest.CONTROL_AE_MODE_ON
}
private fun flashMode(mode: String): Int =
if (flashAeMode(mode) == CaptureRequest.CONTROL_AE_MODE_ON) CaptureRequest.FLASH_MODE_OFF
else CaptureRequest.FLASH_MODE_SINGLE
fun capture(promise: Promise, flash: String) {
val dev = device
val s = session
val ir = reader
@@ -521,17 +552,34 @@ 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)
req.set(CaptureRequest.FLASH_MODE, flashMode(flash))
req.set(CaptureRequest.CONTROL_AE_MODE, flashAeMode(flash))
// 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.
// first frames can still come off the biased exposure. That same trigger
// is what lets a flash mode on this request be metered at all: the
// precapture sequence is what returns AE_STATE_FLASH_REQUIRED, the answer
// the HAL fires on in 'auto'.
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)
s.capture(req.build(), object : CameraCaptureSession.CaptureCallback() {
override fun onCaptureCompleted(
s2: CameraCaptureSession,
request: CaptureRequest,
result: TotalCaptureResult
) {
Log.i(
TAG,
"still flash=${result.get(CaptureResult.FLASH_STATE)} ae=" +
"${result.get(CaptureResult.CONTROL_AE_STATE)}"
)
}
}, camHandler)
} catch (e: Throwable) {
fail("ERR_CAPTURE", "Capture request failed: ${e.message}")
return
@@ -729,12 +777,15 @@ class UltraWideModule : Module() {
Prop("cover") { view: UltraWidePreviewView, cover: Boolean -> view.setCover(cover) }
}
AsyncFunction("captureAsync") { promise: Promise ->
// flashMode is the JS 'off' | 'auto' | 'on' the viewfinder is showing; the
// view clamps it to what this lens can actually drive. Expo only recognises
// the trailing Promise as the resolve handle, so it goes last.
AsyncFunction("captureAsync") { flashMode: String, 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)
view.capture(promise, flashMode)
}
}
+8 -2
View File
@@ -1,12 +1,18 @@
import { requireNativeModule, requireNativeViewManager } from 'expo-modules-core';
import type { ComponentType } from 'react';
import type { ViewProps } from 'react-native';
import type { FlashMode } from '../../src/types';
export interface UltraWideModule {
/** Hidden back ultra-wide camera id ('' when this device has none). */
availableAsync(): Promise<string>;
/** Still capture from the ultra-wide lens; resolves to an absolute file path. */
captureAsync(): Promise<string>;
/**
* Still capture from the ultra-wide lens; resolves to an absolute file path.
* `flashMode` is the viewfinder's setting, driven straight into the still
* request's FLASH_MODE — the lens falls back to 'off' on its own when it has
* no flash unit to fire.
*/
captureAsync(flashMode: FlashMode): Promise<string>;
/**
* Tap-to-focus + spot metering on the ultra-wide lens, at a point in the
* preview view (React Native dp, same space as a touch's locationX/Y). The