diff --git a/App.tsx b/App.tsx index 72ef5dc..bd01b18 100644 --- a/App.tsx +++ b/App.tsx @@ -9,7 +9,7 @@ import * as Haptics from 'expo-haptics'; import AsyncStorage from '@react-native-async-storage/async-storage'; import TopBar from './src/components/TopBar'; -import Viewfinder from './src/components/Viewfinder'; +import Viewfinder, { type ViewfinderHandle } from './src/components/Viewfinder'; import AdjustmentPanel from './src/components/AdjustmentPanel'; import CameraControls from './src/components/CameraControls'; import ToolRail, { TabId } from './src/components/ToolRail'; @@ -32,6 +32,10 @@ console.error('[camdebug-ver] debug build console.error path'); export default function App() { const [mode, setMode] = useState<'camera' | 'library'>('camera'); + // Imperative handle into the Viewfinder camera controller: flushes the EV to + // the hardware AE bias right before each capture (live slider drags never + // touch the hardware — that flashed the Xiaomi preview). + const viewfinderRef = useRef(null); const [recipes, setRecipes] = useState([]); const [selectedRecipe, setSelectedRecipe] = useState(null); @@ -393,6 +397,14 @@ export default function App() { } const tShot = Date.now(); + // Hardware AE bias for this shot (best-effort, bounded retries inside). + // Applied here — right before the capture — instead of live during EV + // slider drags, which re-locked AE on the Xiaomi and flashed preview. + try { + await viewfinderRef.current?.flushExposureBias(); + } catch (e) { + console.error('[ev] flush at capture failed: ' + String(e)); + } const { filePath } = await photoOutput.capturePhotoToFile({}, {}); console.error(`[capture] shot-took ${Date.now() - tShot}ms`); const sourceUri = toUri(filePath); @@ -504,6 +516,7 @@ export default function App() { {/* Fullscreen hero: photo or live preview fills the whole screen */} void; } +export interface ViewfinderHandle { + /** + * Flush the current EV (adjustments.exposureCompensation) to the camera AE + * bias. Called by App right before a photo capture so the shot carries true + * hardware compensation; never called live while the EV slider drags (each + * hardware bias change re-locks AE on some devices and flashes the preview). + */ + flushExposureBias(): Promise; +} + const noiseEffect = Skia.RuntimeEffect.Make(` vec4 main(vec2 pos) { float r = fract(sin(dot(pos.xy, vec2(12.9898, 78.233))) * 43758.5453); @@ -92,22 +102,25 @@ const F_LINEAR = FilterMode.Linear; const M_NONE = MipmapMode.None; const CLIP_INTERSECT = ClipOp.Intersect; -export default function Viewfinder({ - mode, - recipe, - selectedFrame, - useGeotag, - gpsInfo, - libraryImageUri, - cameraPermissionGranted, - onRequestCameraPermission, - photoOutput, - aspectRatio, - rawEnabled, - rawOutput, - onSessionError, - onExposureChange, -}: ViewfinderProps) { +const Viewfinder = forwardRef(function Viewfinder( + { + mode, + recipe, + selectedFrame, + useGeotag, + gpsInfo, + libraryImageUri, + cameraPermissionGranted, + onRequestCameraPermission, + photoOutput, + aspectRatio, + rawEnabled, + rawOutput, + onSessionError, + onExposureChange, + }: ViewfinderProps, + ref +) { // Fullscreen hero: the viewfinder fills the entire screen, every overlay // rect derives from window size. const { width: vw, height: vh } = useWindowDimensions(); @@ -200,13 +213,14 @@ export default function Viewfinder({ ); const adjustments = recipe.adjustments; - // Camera captures apply exposure compensation as a hardware AE bias (see the - // setExposureBias effect below) — never via the matrix here. Library - // stills have no hardware bias, so the preview matrix gains 2^EV. - const colorMatrix = - mode === 'library' - ? applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), adjustments.exposureCompensation ?? 0) - : getSkiaColorMatrix(recipe.baseFilter, adjustments); + // Exposure compensation, applied as software 2^EV matrix gain for BOTH modes. + // Camera AE bias is only flushed to hardware at capture time + // (flushExposureBias): setting it live per slider value re-locks AE on the + // Xiaomi and flashes the preview (matrix change is instant and flicker-free). + const colorMatrix = applyExposureGain( + getSkiaColorMatrix(recipe.baseFilter, adjustments), + adjustments.exposureCompensation ?? 0 + ); // The Skia onFrame worklet runs on a separate runtime — keep the latest color // matrix in a cross-thread synchronizable value instead of a Reanimated @@ -239,12 +253,10 @@ export default function Viewfinder({ cinemaSync.setBlocking(cinemaParams ? cinemaParams.flat : cinemaOffFlat()); }, [colorMatrix, toneParams, cinemaParams, colorMatrixSync, toneSync, cinemaSync]); - // Exposure compensation → hardware AE bias. Passed imperatively (never as a - // declarative prop): v5 calls setExposureBias while the session is still - // starting up and throws "Camera is not active" — seen on the Xiaomi at boot. - // The controller only exists after onStarted, so it is safe to bias then. - // Deps on the controller identity (not just cameraActive): a session restart - // swaps the controller and silently resets the bias to 0. + // Camera controller lives behind SkiaCamera. AE bias is not tracked live: + // flushExposureBias() below applies it right before each photo capture so the + // shot carries true hardware compensation while EV slider drags stay + // flicker-free (preview EV is software matrix gain). const skiaCameraRef = useRef(null); const [cameraActive, setCameraActive] = useState(false); // Camera session pauses when the app backgrounds; toggling isActive back on @@ -274,55 +286,38 @@ export default function Viewfinder({ console.error(`[ev] device has no exposure bias support`); } }, [controller]); - useEffect(() => { - if (!controller) return; - const d = controller.device; - if (!d || !d.supportsExposureBias) { - console.error(`[ev] device has no exposure bias support`); - return; - } - // CameraX exposes *compensation indexes*, not EV stops: the Android - // implementation runs setExposureCompensationIndex(exposure.toInt()), and - // 1 index = 1/8 EV on this device class (range -24..24 -> ±3 EV). The UI - // slider is real EV, so map EV -> index and clamp to the sensor range. - // Without this, +0.5 EV truncated to index 0 (no change) and +3 EV became - // index 3 = 0.375 EV — the "EV barely changes" report. - const index = Math.round(evStops * 8); - const clamped = Math.max(d.minExposureBias, Math.min(d.maxExposureBias, index)); - // CameraX cancels an in-flight bias change whenever another compensation - // request lands (session reconfig, AE restart) — retry a few times, but - // only while this value is still the latest one the user asked for. - // The slider emits many values per drag: fire setExposureBias only after a - // short quiet period, so a stream of EV changes collapses into one camera - // call. Without this, each intermediate value queued its own retry chain - // and the in-flight cancels (with AE re-lock on the Xiaomi) caused preview - // flicker while dragging EV. - const timers: ReturnType[] = []; - let attempt = 0; - let disposed = false; - const trySet = () => { - if (disposed || evStopsRef.current !== evStops) return; // stale — a newer drag is pending - attempt += 1; - controller - .setExposureBias(clamped) - .then(() => { - console.error(`[ev] ev=${evStops} -> idx=${clamped} ok, read=${controller.exposureBias}`); - }) - .catch((e) => { - console.error(`[ev] ev=${evStops} idx=${clamped} attempt=${attempt} failed: ${String(e).slice(0, 100)}`); - if (attempt < 5 && String(e).includes('Cancel')) { - timers.push(setTimeout(trySet, 150)); + // Hardware AE bias is applied ONLY at capture time via flushExposureBias() + // (called by App right before the shutter) — never live while the EV slider + // drags: each setExposureBias re-locks AE on the Xiaomi and flashes the + // preview. The preview shows the EV through the software matrix above. + // CameraX exposes compensation *indexes* (1 index = 1/8 EV on this device + // class, range -24..24 = ±3 EV): map EV -> index, clamp to the device range, + // retry on Cancel (a session reconfig cancels an in-flight bias change). + useImperativeHandle( + ref, + () => ({ + async flushExposureBias() { + const ctl = skiaCameraRef.current?.controller; + const d = ctl?.device; + if (!ctl || !d || !d.supportsExposureBias) return; + const ev = evStopsRef.current; + const index = Math.round(ev * 8); + const clamped = Math.max(d.minExposureBias, Math.min(d.maxExposureBias, index)); + if (clamped === ctl.exposureBias) return; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + await ctl.setExposureBias(clamped); + console.error(`[ev] flush ev=${ev} idx=${clamped} ok, read=${ctl.exposureBias}`); + return; + } catch (e) { + console.error(`[ev] flush ev=${ev} idx=${clamped} attempt=${attempt} failed: ${String(e).slice(0, 100)}`); + await new Promise((r) => setTimeout(r, 80)); } - }); - }; - const debounce = setTimeout(trySet, 110); - return () => { - disposed = true; - clearTimeout(debounce); - timers.forEach((t) => clearTimeout(t)); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [controller, evStops]); + } + }, + }), + [ref] + ); // Tap-to-focus: lock AE/AF/AWB onto the tapped region (SceneAdaptiveness // 'locked' + autoResetAfter null = stays locked until another tap or unlock). @@ -362,9 +357,8 @@ export default function Viewfinder({ }; // Quick exposure slider — appears while AE/AF is locked. Drag up/down maps - // directly to the camera AE bias in EV stops (same path as the recipe EV - // slider: Viewfinder already syncs adjustments.exposureCompensation to the - // controller via setExposureBias, and captures apply the bias on-device). + // directly to the recipe EV in stops (same live software-matrix path as the + // EV slider; flushExposureBias applies it to the camera at capture time). const EV_RANGE = 3; // matches the ±24 index range at 1 index = 1/8 EV const evTrackTop = vh * 0.24; const evTrackH = vh * 0.42; @@ -1308,4 +1302,6 @@ export default function Viewfinder({ )} ); -} +}); + +export default Viewfinder;