fix(ev): preview EV via software matrix, flush AE bias only at capture

This commit is contained in:
2026-09-08 21:28:38 +07:00
parent dca4ba0b7c
commit e9a9005ef2
2 changed files with 92 additions and 83 deletions
+14 -1
View File
@@ -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<ViewfinderHandle>(null);
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(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 */}
<View className="absolute inset-0 bg-black">
<Viewfinder
ref={viewfinderRef}
mode={mode}
recipe={
selectedRecipe || {
+78 -82
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle, type ComponentProps } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions, AppState } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useCameraDevice, type CameraPhotoOutput } from 'react-native-vision-camera';
@@ -54,6 +54,16 @@ interface ViewfinderProps {
onExposureChange?: (ev: number) => 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<void>;
}
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<ViewfinderHandle, ViewfinderProps>(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<SkiaCameraRef>(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<typeof setTimeout>[] = [];
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({
)}
</View>
);
}
});
export default Viewfinder;