From c40b5565d3cfca8546433080a10a29cbe57d4bee Mon Sep 17 00:00:00 2001 From: locphamtran Date: Wed, 16 Sep 2026 18:11:17 +0700 Subject: [PATCH] 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. --- App.tsx | 50 +++- .../ultrawide/UltraWideModule.kt | 61 ++++- modules/recipescam-ultrawide/index.ts | 10 +- src/components/CameraControls.tsx | 217 +++++++++++------- src/types/index.ts | 3 + 5 files changed, 242 insertions(+), 99 deletions(-) diff --git a/App.tsx b/App.tsx index 492ef58..52cc9de 100644 --- a/App.tsx +++ b/App.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { View, StatusBar, Alert, ActivityIndicator, Text, AppState, Platform, Keyboard } from 'react-native'; -import { useCameraPermission, usePhotoOutput } from 'react-native-vision-camera'; +import { useCameraDevice, useCameraPermission, usePhotoOutput } from 'react-native-vision-camera'; import * as DocumentPicker from 'expo-document-picker'; import * as Sharing from 'expo-sharing'; import * as FileSystem from 'expo-file-system/legacy'; @@ -18,7 +18,7 @@ import PhotoViewerModal from './src/components/PhotoViewerModal'; import LibraryPickerModal from './src/components/LibraryPickerModal'; import ProModal from './src/components/ProModal'; -import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } from './src/types'; +import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, FlashMode, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } from './src/types'; import { getAllRecipes, saveCustomRecipe, updateCustomRecipe, deleteCustomRecipe, getFavoriteIds, setFavoriteId } from './src/utils/storageUtils'; import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from './src/utils/defaultRecipes'; import { getCurrentGPS, requestLocationPermissions, reverseGeocode } from './src/utils/locationUtils'; @@ -269,6 +269,10 @@ export default function App() { const [settingsVisible, setSettingsVisible] = useState(false); // Shutter click: only OFF/ON (no volume API on Android). Applied at capture. const [shutterSound, setShutterSound] = useState('on'); + // LED flash mode. Capture-time only (no live torch): 'off' keeps today's + // behaviour, 'auto' lets the pipeline decide, 'on' always fires. Applied to + // the still, so the preview itself never changes brightness. + const [flashMode, setFlashMode] = useState('off'); // Touch-to-shutter (gear sheet): a plain tap on the live viewfinder locks // AE/AF/AWB on the tapped point and, once focus has settled, fires a capture. // OFF = a tap only focuses (the historical behaviour). @@ -297,6 +301,10 @@ export default function App() { // Hardware Permissions (vision-camera) const { hasPermission: cameraPermissionGranted, requestPermission: requestCameraPermission } = useCameraPermission(); + // The lens the flash control belongs to. CameraX throws when asked for + // 'on'/'auto' on a device with no flash unit (the front lens, most tablets), + // so the control is only offered while this reads true. + const flashAvailable = useCameraDevice(cameraFacing)?.hasFlash ?? false; // Full-resolution photo output — attached to the live in Viewfinder. // Photo capture is resolution-independent from the Skia preview output. const photoOutput = usePhotoOutput({ @@ -393,6 +401,7 @@ export default function App() { if (raw) { const s = JSON.parse(raw); if (s.shutterSound === 'on' || s.shutterSound === 'off') setShutterSound(s.shutterSound); + if (s.flashMode === 'off' || s.flashMode === 'auto' || s.flashMode === 'on') setFlashMode(s.flashMode); if (s.meterMode === 'evaluative' || s.meterMode === 'highlight') setMeterMode(s.meterMode); if (typeof s.rawEnabled === 'boolean') setRawEnabled(s.rawEnabled); if (typeof s.gpsEnabled === 'boolean') setGpsEnabled(s.gpsEnabled); @@ -891,9 +900,9 @@ export default function App() { if (!settingsHydratedRef.current) return; AsyncStorage.setItem( CAMERA_SETTINGS_KEY, - JSON.stringify({ shutterSound, meterMode, rawEnabled, gpsEnabled, touchToShutter }) + JSON.stringify({ shutterSound, flashMode, meterMode, rawEnabled, gpsEnabled, touchToShutter }) ).catch(() => {}); - }, [shutterSound, meterMode, rawEnabled, gpsEnabled, touchToShutter]); + }, [shutterSound, flashMode, meterMode, rawEnabled, gpsEnabled, touchToShutter]); // Latest-rawEnabled ref so the stable session-error callback below can read // the live toggle without re-subscribing the Camera session on every render. @@ -1319,16 +1328,23 @@ export default function App() { // 0.5x is a native Camera2 session on the hidden ultra-wide lens, not a // CameraX zoom ratio: no CameraX session is bound while it is live, so // the still has to come from the native module — and Camera2 fires no - // shutter sound of its own. + // shutter sound of its own. The LED is a CameraX capture setting, so the + // native module re-applies the viewfinder's flashMode on its own still + // request, and drops it back to 'off' on a lens with no flash unit. const ultraWideShot = viewfinderRef.current?.isUltraWide?.() === true; let filePath: string; if (ultraWideShot) { - filePath = await UltraWide.captureAsync(); + filePath = await UltraWide.captureAsync(flashMode); tSensor = Date.now(); } else { filePath = ( await photoOutput.capturePhotoToFile( - { enableShutterSound: shutterSound === 'on' && !opts?.burst }, + { + enableShutterSound: shutterSound === 'on' && !opts?.burst, + // Capture-time LED flash. Forced back to 'off' on a lens with no + // flash unit: the spec throws for anything else. + flashMode: flashAvailable ? flashMode : 'off', + }, { onDidCapturePhoto: () => { tSensor = Date.now(); } }, ) ).filePath; @@ -1340,7 +1356,16 @@ export default function App() { // "RAW" album without blocking the JPEG export below. if (rawEnabled && rawPhotoOutput && !opts?.burst && !ultraWideShot) { try { - const rawFile = await rawPhotoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {}); + const rawFile = await rawPhotoOutput.capturePhotoToFile( + { + enableShutterSound: shutterSound === 'on', + // Same light as the JPEG it follows: the flash unit may still be + // recharging tens of ms later, in which case the DNG just lands + // darker than its JPEG — a silent raw, never a broken capture. + flashMode: flashAvailable ? flashMode : 'off', + }, + {}, + ); const rawUri = toUri(rawFile.filePath); saveRawToAlbum(rawUri).then((ok) => { if (!ok) { @@ -1876,6 +1901,15 @@ export default function App() { onPickImage={handlePickImage} onOpenPreview={openLatestViewer} onOpenSettings={() => setSettingsVisible(true)} + // Capture-time LED flash: offered only on a lens that carries a flash + // unit, and only in camera mode (the library save never fires the + // camera). Tap cycles OFF -> AUTO -> ON -> OFF. + flashMode={mode === 'camera' && flashAvailable ? flashMode : undefined} + onCycleFlash={ + mode === 'camera' && flashAvailable + ? () => setFlashMode((m) => (m === 'off' ? 'auto' : m === 'auto' ? 'on' : 'off')) + : undefined + } onFlipCamera={() => setCameraFacing((f) => (f === 'back' ? 'front' : 'back'))} isLibraryMode={mode === 'library'} onSwitchToCamera={() => handleSetMode('camera')} 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 d421eb7..c4bf38d 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 @@ -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) } } diff --git a/modules/recipescam-ultrawide/index.ts b/modules/recipescam-ultrawide/index.ts index aa6e149..8cfb61f 100644 --- a/modules/recipescam-ultrawide/index.ts +++ b/modules/recipescam-ultrawide/index.ts @@ -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; - /** Still capture from the ultra-wide lens; resolves to an absolute file path. */ - captureAsync(): Promise; + /** + * 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; /** * 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 diff --git a/src/components/CameraControls.tsx b/src/components/CameraControls.tsx index 7c42aae..390b4f0 100644 --- a/src/components/CameraControls.tsx +++ b/src/components/CameraControls.tsx @@ -1,7 +1,18 @@ import React from 'react'; import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Image as ImageIcon, Eye, Download, Camera, Settings, RefreshCw } from 'lucide-react-native'; +import { Image as ImageIcon, Eye, Download, Camera, Settings, RefreshCw, Zap, ZapOff } from 'lucide-react-native'; +import type { FlashMode } from '../types'; + +// "A" badge tucked into the corner of the flash button's AUTO state. +const FLASH_A_BADGE = { + position: 'absolute' as const, + bottom: 6, + right: 12, + fontSize: 9, + fontWeight: '700' as const, + color: '#f4f4f5', +}; interface CameraControlsProps { lastPhotoUri: string | null; @@ -13,6 +24,10 @@ interface CameraControlsProps { onPickImage: () => void; onOpenPreview: () => void; onOpenSettings: () => void; + // LED flash state for the next still. Absent = no flash control to show + // (library mode, or a lens with no flash unit). + flashMode?: FlashMode; + onCycleFlash?: () => void; onFlipCamera?: () => void; isLibraryMode: boolean; onSwitchToCamera?: () => void; @@ -26,6 +41,8 @@ export default function CameraControls({ onPickImage, onOpenPreview, onOpenSettings, + flashMode, + onCycleFlash, onFlipCamera, isLibraryMode, onSwitchToCamera, @@ -33,100 +50,132 @@ export default function CameraControls({ const insets = useSafeAreaInsets(); return ( - - {/* Left cluster: Pick Image + gear (gear sits between library & shutter). - flex-1 side zones are equal width, so the shutter between them is - exactly centered on the screen regardless of button counts. */} - - - - - - - - - - - {/* Dynamic Center Button: Shutter or Save */} - - {isLibraryMode ? ( - - - - - - ) : ( - - - - - - )} - - - {/* Right cluster: flip camera (between shutter & preview) + preview. - Preview doubles as "back to camera" while in library mode; the flip - control is hidden then. Anchored to the right edge inside the flex-1 - side zone; small gap keeps it clear of the preview thumbnail without - drifting toward the shutter. - - Exports land in the session library, so once library mode has one its - thumbnail takes the slot the flip control would have used — that keeps - "back to camera" untouched while the finished photo stays reachable - (review or trash) after the auto-opened viewer is closed. */} - - {isLibraryMode ? ( - lastPhotoUri ? ( - - - - ) : null - ) : onFlipCamera ? ( + + {/* Capture-time LED flash: a button of its own above the control row + rather than a third button inside it. The two side zones are sized to + hold two 48dp buttons next to the shutter — a third would slide under + the shutter's hit area on a 360dp screen. Absent on a lens with no + flash unit, so the front camera shows no dead control. */} + {flashMode && onCycleFlash ? ( + - + {flashMode === 'off' ? ( + + ) : ( + // AUTO keeps the bolt dim and adds the badge below — a lit bolt + // alone would read as ON. + + )} + {flashMode === 'auto' ? ( + A + ) : null} - ) : null} + + ) : null} + + + {/* Left cluster: Pick Image + gear (gear sits between library & shutter). + flex-1 side zones are equal width, so the shutter between them is + exactly centered on the screen regardless of button counts. */} + + + + + + + + + + + + {/* Dynamic Center Button: Shutter or Save */} {isLibraryMode ? ( - - ) : lastPhotoUri ? ( - + + + + + ) : ( - + + + + + )} + + {/* Right cluster: flip camera (between shutter & preview) + preview. + Preview doubles as "back to camera" while in library mode; the flip + control is hidden then. Anchored to the right edge inside the flex-1 + side zone; small gap keeps it clear of the preview thumbnail without + drifting toward the shutter. + + Exports land in the session library, so once library mode has one its + thumbnail takes the slot the flip control would have used — that keeps + "back to camera" untouched while the finished photo stays reachable + (review or trash) after the auto-opened viewer is closed. */} + + {isLibraryMode ? ( + lastPhotoUri ? ( + + + + ) : null + ) : onFlipCamera ? ( + + + + ) : null} + + {isLibraryMode ? ( + + ) : lastPhotoUri ? ( + + ) : ( + + )} + + ); diff --git a/src/types/index.ts b/src/types/index.ts index f3e8da4..0a0fff6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,6 +13,9 @@ export type MeterMode = 'evaluative' | 'highlight'; // Camera shutter sound. OS policy may force the click on in some regions; // there is no native volume control (OFF/ON only). export type ShutterSound = 'on' | 'off'; +// LED flash for photo capture (never a live torch): 'auto' lets the capture +// pipeline decide from the metered scene, 'on' always fires, 'off' never does. +export type FlashMode = 'off' | 'auto' | 'on'; export type StartupMode = 'default' | 'last'; // One photo in the in-app session history (recent captures + library exports). // uri = app-cache JPEG; assetId (when savedToLibrary) = the MediaLibrary asset