feat: v1.1 — highlight-weighted metering, settings modal, photo viewer, shutter sound control, cleanup
This commit is contained in:
+177
-73
@@ -23,7 +23,7 @@ import {
|
||||
MipmapMode,
|
||||
} from '@shopify/react-native-skia';
|
||||
import { Lock } from 'lucide-react-native';
|
||||
import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H } from '../types';
|
||||
import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments, ASPECT_RATIO_W_H } from '../types';
|
||||
import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils';
|
||||
import { TONE_SKSL, getToneUniforms } from '../utils/toneShader';
|
||||
import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader';
|
||||
@@ -52,6 +52,31 @@ interface ViewfinderProps {
|
||||
onSessionError?: (error: Error) => void;
|
||||
// Quick exposure slider (shown while AE/AF is locked) — live AE bias in EV.
|
||||
onExposureChange?: (ev: number) => void;
|
||||
// Metering-mode EV offset (highlight-weighted): extra EV stops ADDED on top
|
||||
// of the recipe EV. Applied to the preview matrix AND the capture-time AE
|
||||
// flush so preview/export/capture agree; the EV slider UI keeps showing the
|
||||
// raw user EV. Negative = protect highlights. 0 = off.
|
||||
meteringEvOffset?: number;
|
||||
// Metering-mode tonal look (highlight-weighted): small extra adjustments
|
||||
// merged on top of the recipe for RENDERING only (preview matrix + tone, and
|
||||
// App mirrors it into the export). Real highlight-weighted metering (GR /
|
||||
// Sony) doesn't only underexpose — it meters for the BRIGHTEST area to keep
|
||||
// its detail (absolute no-blowout): highlight roll, deep shadow crush, raised
|
||||
// contrast/clarity. Never contains exposureCompensation (that rides the EV
|
||||
// offset above); UI sliders keep showing the raw recipe.
|
||||
meteringAdjustments?: Partial<ColorAdjustments>;
|
||||
// Continuous panel parameter whose slider row is open (App lifts it from the
|
||||
// AdjustmentPanel). While set, a vertical drag ANYWHERE on the live image
|
||||
// adjusts this parameter (up = increase) — no need to grab the small slider.
|
||||
// Null while no param row is open; then a locked-AE drag adjusts EV instead.
|
||||
imageAdjustTarget?: {
|
||||
key: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ViewfinderHandle {
|
||||
@@ -81,7 +106,7 @@ const toneEffect = (() => {
|
||||
try {
|
||||
return Skia.RuntimeEffect.Make(TONE_SKSL);
|
||||
} catch (e) {
|
||||
console.error('[camtone] TONE_SKSL compile failed: ' + e);
|
||||
console.error('TONE_SKSL compile failed: ' + e);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
@@ -90,7 +115,7 @@ const cinemaEffect = (() => {
|
||||
try {
|
||||
return Skia.RuntimeEffect.Make(CINEMA_SKSL);
|
||||
} catch (e) {
|
||||
console.error('[cinema] CINEMA_SKSL compile failed: ' + e);
|
||||
console.error('CINEMA_SKSL compile failed: ' + e);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
@@ -118,6 +143,9 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
rawOutput,
|
||||
onSessionError,
|
||||
onExposureChange,
|
||||
meteringEvOffset = 0,
|
||||
meteringAdjustments,
|
||||
imageAdjustTarget,
|
||||
}: ViewfinderProps,
|
||||
ref
|
||||
) {
|
||||
@@ -212,14 +240,20 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
Math.round(vw * 0.032)
|
||||
);
|
||||
|
||||
const adjustments = recipe.adjustments;
|
||||
const adjustments = meteringAdjustments
|
||||
? { ...recipe.adjustments, ...meteringAdjustments }
|
||||
: recipe.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).
|
||||
// Metering offset (highlight-weighted) rides on top of the user EV so the
|
||||
// preview shows the protected-exposure look; EV_RANGE ±3 caps the software
|
||||
// gain (flush clamps to the device range separately).
|
||||
const evApplied = Math.max(-3, Math.min(3, (adjustments.exposureCompensation ?? 0) + meteringEvOffset));
|
||||
const colorMatrix = applyExposureGain(
|
||||
getSkiaColorMatrix(recipe.baseFilter, adjustments),
|
||||
adjustments.exposureCompensation ?? 0
|
||||
evApplied
|
||||
);
|
||||
|
||||
// The Skia onFrame worklet runs on a separate runtime — keep the latest color
|
||||
@@ -259,6 +293,15 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
// flicker-free (preview EV is software matrix gain).
|
||||
const skiaCameraRef = useRef<SkiaCameraRef>(null);
|
||||
const [cameraActive, setCameraActive] = useState(false);
|
||||
// Stable identities so React.memo(SkiaCameraImpl) does not re-render (and
|
||||
// re-run its frame-output setup) on every adjustments change during slider
|
||||
// drags — inline lambdas here used to break memo and stall the main thread.
|
||||
const handleCameraStarted = useCallback(() => {
|
||||
setCameraActive(true);
|
||||
}, []);
|
||||
const handleCameraStopped = useCallback(() => {
|
||||
setCameraActive(false);
|
||||
}, []);
|
||||
// Camera session pauses when the app backgrounds; toggling isActive back on
|
||||
// resume restarts it. Without this the preview stayed black after unlock.
|
||||
const [appActive, setAppActive] = useState(AppState.currentState === 'active');
|
||||
@@ -275,15 +318,16 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
// per intermediate value — each one cancelled by the next, spamming [ev]).
|
||||
const evStopsRef = useRef(evStops);
|
||||
evStopsRef.current = evStops;
|
||||
// Metering offset mirrored into a ref: flushExposureBias is a stable
|
||||
// imperative handle (useImperativeHandle deps [ref]) and must read the LIVE
|
||||
// offset when the user switches metering mode.
|
||||
const meteringEvOffsetRef = useRef(meteringEvOffset);
|
||||
meteringEvOffsetRef.current = meteringEvOffset;
|
||||
useEffect(() => {
|
||||
if (!controller) return;
|
||||
const d = controller.device;
|
||||
if (d && d.supportsExposureBias) {
|
||||
console.error(
|
||||
`[ev] device range=${d.minExposureBias}..${d.maxExposureBias} current=${controller.exposureBias}`
|
||||
);
|
||||
} else {
|
||||
console.error(`[ev] device has no exposure bias support`);
|
||||
if (d && !d.supportsExposureBias) {
|
||||
console.warn('Device has no exposure-bias support — EV applies to preview and export only.');
|
||||
}
|
||||
}, [controller]);
|
||||
// Hardware AE bias is applied ONLY at capture time via flushExposureBias()
|
||||
@@ -300,17 +344,16 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const ctl = skiaCameraRef.current?.controller;
|
||||
const d = ctl?.device;
|
||||
if (!ctl || !d || !d.supportsExposureBias) return;
|
||||
const ev = evStopsRef.current;
|
||||
const ev = evStopsRef.current + meteringEvOffsetRef.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)}`);
|
||||
console.warn(`Exposure-bias flush failed (attempt ${attempt}): ${String(e).slice(0, 100)}`);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
}
|
||||
}
|
||||
@@ -330,7 +373,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const d = cam?.controller?.device;
|
||||
if (!d) return;
|
||||
if (!d.supportsFocusMetering && !d.supportsExposureMetering && !d.supportsWhiteBalanceMetering) {
|
||||
console.error('[focus] metering unsupported on this device');
|
||||
console.warn('Focus/exposure metering unsupported on this device');
|
||||
return;
|
||||
}
|
||||
setFocusPoint({ x, y });
|
||||
@@ -339,9 +382,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
{ x, y },
|
||||
{ responsiveness: 'snappy', adaptiveness: 'locked', autoResetAfter: null }
|
||||
);
|
||||
console.error(`[focus] tap=${Math.round(x)},${Math.round(y)} AE/AF/AWB locked`);
|
||||
} catch (e) {
|
||||
console.error('[focus] lock failed: ' + String(e).slice(0, 120));
|
||||
console.warn('Focus lock failed: ' + String(e).slice(0, 120));
|
||||
}
|
||||
};
|
||||
const handleUnlockFocus = async () => {
|
||||
@@ -350,9 +392,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
try {
|
||||
await cam.resetFocus();
|
||||
setFocusPoint(null);
|
||||
console.error('[focus] reset to continuous auto');
|
||||
} catch (e) {
|
||||
console.error('[focus] reset failed: ' + String(e).slice(0, 120));
|
||||
console.warn('Focus reset failed: ' + String(e).slice(0, 120));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -365,41 +406,114 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const evPxPerStop = evTrackH / (2 * EV_RANGE);
|
||||
const clampEv = (v: number) => Math.max(-EV_RANGE, Math.min(EV_RANGE, v));
|
||||
const evDragRef = useRef<{ y0: number; ev0: number } | null>(null);
|
||||
// Last EV value actually pushed to App. Quick-EV drags otherwise fire a full
|
||||
// adjustments re-render on EVERY pointer move (30-60Hz), even while the
|
||||
// finger holds a step without the value changing — that render storm stalls
|
||||
// the main thread and beats the Skia preview. Only push real nấc changes.
|
||||
const evLastSentRef = useRef<number | null>(null);
|
||||
const evThumbY = evTrackTop + ((EV_RANGE - clampEv(evStops)) / (2 * EV_RANGE)) * evTrackH;
|
||||
// Screen-absolute Y of the gesture. locationY is relative to whichever view
|
||||
// the finger currently hit-tests over, so crossing the small track's edge
|
||||
// mid-drag changed the origin by the track offset and snapped EV by ~3 stops
|
||||
// (the "jumps to the other end" bug). pageY never moves.
|
||||
const pageYOf = (e: any): number => {
|
||||
const t = e?.nativeEvent?.touches?.[0];
|
||||
if (t && typeof t.pageY === 'number') return t.pageY;
|
||||
const p = e?.nativeEvent?.pageY;
|
||||
if (typeof p === 'number') return p;
|
||||
return e?.nativeEvent?.locationY ?? 0;
|
||||
};
|
||||
const onEvSliderGrant = (e: any) => {
|
||||
evDragRef.current = { y0: e.nativeEvent.locationY, ev0: evStops };
|
||||
const y0 = pageYOf(e);
|
||||
evDragRef.current = { y0, ev0: evStops };
|
||||
evLastSentRef.current = evStops;
|
||||
};
|
||||
const onEvSliderMove = (e: any) => {
|
||||
const g = evDragRef.current;
|
||||
if (!g || !onExposureChange) return;
|
||||
const dy = g.y0 - e.nativeEvent.locationY; // drag up = brighter
|
||||
const dy = g.y0 - pageYOf(e); // drag up = brighter
|
||||
const ev = clampEv(g.ev0 + dy / evPxPerStop);
|
||||
onExposureChange(Math.round(ev * 10) / 10);
|
||||
const rounded = Math.round(ev * 10) / 10;
|
||||
if (rounded === evLastSentRef.current) return; // same step: skip re-render
|
||||
evLastSentRef.current = rounded;
|
||||
onExposureChange(rounded);
|
||||
};
|
||||
const onEvSliderRelease = () => {
|
||||
evDragRef.current = null;
|
||||
};
|
||||
|
||||
// Instrumentation for device logs (user captures only logcat E level, so
|
||||
// console.log/warn never show up — these are error-level on purpose).
|
||||
const lastPreviewLog = useRef(0);
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastPreviewLog.current < 400) return;
|
||||
lastPreviewLog.current = now;
|
||||
console.error(
|
||||
`[preview] matrix=(${colorMatrix[0].toFixed(3)},${colorMatrix[5].toFixed(3)},${colorMatrix[10].toFixed(3)}) tone=[${toneParams.join(',')}] ev=${evStops}`
|
||||
);
|
||||
}, [colorMatrix, toneParams, evStops]);
|
||||
// ---- Drag-anywhere-on-the-image adjustment --------------------------------
|
||||
// Priority: an open panel param row (imageAdjustTarget) > EV (AE locked).
|
||||
// A plain tap (no movement) always falls through to tap-to-focus.
|
||||
const TAP_SLOP = 14; // px of movement before a touch counts as a drag
|
||||
const imageAdjustTargetRef = useRef(imageAdjustTarget);
|
||||
imageAdjustTargetRef.current = imageAdjustTarget;
|
||||
type ImgDragSession = {
|
||||
y0: number;
|
||||
kind: 'param' | 'ev' | 'focus';
|
||||
v0: number;
|
||||
param: { min: number; max: number; step: number; onChange: (v: number) => void };
|
||||
moved: boolean;
|
||||
lastSent: number | null;
|
||||
};
|
||||
const imgDragRef = useRef<ImgDragSession | null>(null);
|
||||
const onImageDragGrant = (e: any) => {
|
||||
const t = imageAdjustTargetRef.current;
|
||||
let kind: ImgDragSession['kind'] = 'focus';
|
||||
let v0 = 0;
|
||||
let param: ImgDragSession['param'] = { min: 0, max: 0, step: 0, onChange: () => {} };
|
||||
if (t) {
|
||||
kind = 'param';
|
||||
v0 = t.value;
|
||||
param = { min: t.min, max: t.max, step: t.step, onChange: t.onChange };
|
||||
} else if (focusPoint && onExposureChange) {
|
||||
kind = 'ev';
|
||||
v0 = evStops;
|
||||
}
|
||||
imgDragRef.current = { y0: pageYOf(e), kind, v0, param, moved: false, lastSent: null };
|
||||
};
|
||||
const onImageDragMove = (e: any) => {
|
||||
const g = imgDragRef.current;
|
||||
if (!g || g.kind === 'focus') return;
|
||||
const dy = g.y0 - pageYOf(e); // up = positive / increase
|
||||
if (!g.moved) {
|
||||
if (Math.abs(dy) < TAP_SLOP) return;
|
||||
g.moved = true;
|
||||
}
|
||||
if (g.kind === 'ev') {
|
||||
if (!onExposureChange) return;
|
||||
const ev = clampEv(g.v0 + dy / evPxPerStop);
|
||||
const rounded = Math.round(ev * 10) / 10;
|
||||
if (rounded === g.lastSent) return;
|
||||
g.lastSent = rounded;
|
||||
evLastSentRef.current = rounded;
|
||||
onExposureChange(rounded);
|
||||
return;
|
||||
}
|
||||
const range = g.param.max - g.param.min;
|
||||
let v = g.v0 + (dy * range) / evTrackH; // same feel as the EV track
|
||||
v = Math.max(g.param.min, Math.min(g.param.max, v));
|
||||
if (g.param.step > 0) v = Math.round(v / g.param.step) * g.param.step;
|
||||
const rounded = Math.round(v * 1000) / 1000;
|
||||
if (rounded === g.lastSent) return;
|
||||
g.lastSent = rounded;
|
||||
g.param.onChange(rounded);
|
||||
};
|
||||
const onImageDragEnd = (e: any) => {
|
||||
const g = imgDragRef.current;
|
||||
imgDragRef.current = null;
|
||||
if (!g) return;
|
||||
if (!g.moved) {
|
||||
// Plain tap: lock focus at the tapped point (drag never re-locks).
|
||||
handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY);
|
||||
}
|
||||
};
|
||||
|
||||
// Camera frame renderer. Stable identity (empty-ish deps): SkiaCamera re-runs
|
||||
// its frame pipeline whenever onFrame changes, which during slider drags made
|
||||
// the preview flicker. All live values flow through the synchronizables.
|
||||
type SkiaCameraOnFrame = NonNullable<ComponentProps<typeof SkiaCamera>['onFrame']>;
|
||||
const toneFailLogged = useMemo(() => createSynchronizable<boolean>(false), []);
|
||||
// DEBUG-4x3 (remove after diagnosis): log actual stream geometry at runtime.
|
||||
const dbgCountSync = useMemo(() => createSynchronizable<number>(0), []);
|
||||
const dbgLastSync = useMemo(() => createSynchronizable<string>(''), []);
|
||||
// Composition band fed to the frame worklet: [active, viewAspect, fx, fy,
|
||||
// fw, fh] where (fx,fy,fw,fh) is the photo-area rect (ratio band / polaroid
|
||||
// window / wall window) normalized to the view. While active the worklet
|
||||
@@ -418,10 +532,23 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
(frame, render) => {
|
||||
'worklet';
|
||||
render(({ canvas, frameTexture }) => {
|
||||
// Quick-EV drags write colorMatrixSync from JS on every tick; a torn
|
||||
// read can throw inside MakeMatrix (or yield NaN -> black surface) for
|
||||
// one frame. Validate + try/catch, fall back to identity (no filter):
|
||||
// a 1-frame gain error beats a 1-frame black beat. DEBUG-EV.
|
||||
const paint = Skia.Paint();
|
||||
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSync.getBlocking()));
|
||||
const tone = toneSync.getBlocking();
|
||||
const cinema = cinemaSync.getBlocking();
|
||||
try {
|
||||
const cm = colorMatrixSync.getDirty();
|
||||
if (cm != null && cm.length === 20 && cm.every((v) => typeof v === 'number' && Number.isFinite(v))) {
|
||||
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(cm));
|
||||
} else {
|
||||
console.warn('Invalid color matrix (len ' + (cm == null ? 'null' : cm.length) + ') — filter skipped');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Color matrix build failed: ' + e);
|
||||
}
|
||||
const tone = toneSync.getDirty();
|
||||
const cinema = cinemaSync.getDirty();
|
||||
const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0);
|
||||
const hasCinema = cinemaEffect != null && cinema[0] > 0;
|
||||
// Draws the full frame with the tone/cinema shader when active (drawRect,
|
||||
@@ -450,7 +577,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
} catch (e) {
|
||||
if (!toneFailLogged.getBlocking()) {
|
||||
toneFailLogged.setBlocking(true);
|
||||
console.error('[camtone] shader failed, matrix-only: ' + e);
|
||||
console.error('Tone shader failed, matrix-only: ' + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -463,21 +590,12 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
// (under the renderToTexture rotation/mirror CTM) and cover-map the
|
||||
// sensor there. Equal-aspect bands (ratio 4:3 == native 3:4 sensor)
|
||||
// then show the ENTIRE sensor; narrower bands crop like the export.
|
||||
const feed = feedRectSync.getBlocking();
|
||||
const feed = feedRectSync.getDirty();
|
||||
const ftW = frameTexture.width();
|
||||
const ftH = frameTexture.height();
|
||||
const orient = frame.orientation;
|
||||
const dbgMir = frame.isMirrored === true;
|
||||
const dbgCount = dbgCountSync.getBlocking();
|
||||
dbgCountSync.setBlocking(dbgCount + 1);
|
||||
const bandActive = feed[0] > 0;
|
||||
if (!bandActive) {
|
||||
// DEBUG-4x3 (remove): stream ground truth, no-band branch
|
||||
const line = `ft=${ftW}x${ftH} o=${orient} mir=${dbgMir} band=0 feed=[${feed.map((f) => f.toFixed(3)).join(',')}]`;
|
||||
if (dbgCount % 60 === 0 || line !== dbgLastSync.getBlocking()) {
|
||||
dbgLastSync.setBlocking(line);
|
||||
console.error('[camdebug] ' + line);
|
||||
}
|
||||
drawFullFrame();
|
||||
return;
|
||||
}
|
||||
@@ -549,12 +667,6 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const dh = s * ftH;
|
||||
const dx = u0 + (rw - dw) / 2;
|
||||
const dy = v0 + (rh - dh) / 2;
|
||||
// DEBUG-4x3 (remove): full geometry log for the band branch
|
||||
const line = `ft=${ftW}x${ftH} o=${orient} mir=${dbgMir} band=1 feed=[${feed.map((f) => f.toFixed(3)).join(',')}] up=${ow}x${oh} ac=${ac.toFixed(4)} vis=${visX.toFixed(3)}x${visY.toFixed(3)} X0=${X0.toFixed(0)} Y0=${Y0.toFixed(0)} X1=${X1.toFixed(0)} Y1=${Y1.toFixed(0)} raw=${u0.toFixed(0)},${v0.toFixed(0)} ${rw.toFixed(0)}x${rh.toFixed(0)} s=${s.toFixed(4)} d=${dx.toFixed(0)},${dy.toFixed(0)}`;
|
||||
if (dbgCount % 60 === 0 || line !== dbgLastSync.getBlocking()) {
|
||||
dbgLastSync.setBlocking(line);
|
||||
console.error('[camdebug] ' + line);
|
||||
}
|
||||
// Fresh paint; its default color is opaque black (Skia default), so
|
||||
// the surface fill below needs no explicit color set.
|
||||
const blackPaint = Skia.Paint();
|
||||
@@ -644,19 +756,6 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
: [0, 0, 0, 0, 0, 0]
|
||||
);
|
||||
}, [cameraPhotoRect, vw, vh, feedRectSync]);
|
||||
// DEBUG-4x3 (remove): correlate JS view/region state with the worklet log.
|
||||
useEffect(() => {
|
||||
console.error(
|
||||
'[camdebug-js] region=' + JSON.stringify(region) +
|
||||
' band=' + regionBand +
|
||||
' ratio=' + aspectRatio +
|
||||
' frame=' + selectedFrame +
|
||||
' view=' + vw + 'x' + vh +
|
||||
' feed=' + (cameraPhotoRect ? JSON.stringify([1, vw / vh, cameraPhotoRect.x / vw, cameraPhotoRect.y / vh, cameraPhotoRect.w / vw, cameraPhotoRect.h / vh]) : 'null') +
|
||||
' frameTarget=' + (frameTarget ? frameTarget.width + 'x' + frameTarget.height : 'null')
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [region, regionBand, aspectRatio, selectedFrame, cameraPhotoRect]);
|
||||
const onLibTouchStart = (e: any) => {
|
||||
const ts = e.nativeEvent.touches;
|
||||
if (ts.length >= 2) {
|
||||
@@ -981,16 +1080,21 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
outputs={cameraOutputs}
|
||||
pixelFormat="yuv"
|
||||
targetResolution={frameTarget}
|
||||
onStarted={() => setCameraActive(true)}
|
||||
onStarted={handleCameraStarted}
|
||||
onStopped={handleCameraStopped}
|
||||
onFrame={handleFrame}
|
||||
onError={onSessionError}
|
||||
/>
|
||||
{/* Tap-to-focus layer: fullscreen responder (all visual overlays above
|
||||
are pointerEvents none). locationX/Y are relative to this view. */}
|
||||
{/* Gesture layer: tap = AE/AF lock; vertical drag on the image adjusts
|
||||
the open panel parameter (or EV while AE is locked). Sits below the
|
||||
quick-EV strip, which keeps its own narrower direct-drag handle. */}
|
||||
<View
|
||||
className="absolute inset-0"
|
||||
onStartShouldSetResponder={() => true}
|
||||
onResponderGrant={(e) => handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY)}
|
||||
onResponderGrant={onImageDragGrant}
|
||||
onResponderMove={onImageDragMove}
|
||||
onResponderRelease={onImageDragEnd}
|
||||
onResponderTerminate={onImageDragEnd}
|
||||
/>
|
||||
{focusPoint && (
|
||||
<View
|
||||
|
||||
Reference in New Issue
Block a user