fix(camera): matrix-based zoom for pinch/double-tap, tap-to-focus AE/AF lock, library letterbox smear fix, export raw JPEG bytes q95, resume isActive fixes, dark theme polish
This commit is contained in:
+461
-142
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Dimensions, TouchableOpacity, Platform } from 'react-native';
|
||||
import { Camera, useCameraDevice, useSkiaFrameProcessor, type CameraDevice } from 'react-native-vision-camera';
|
||||
import { useSharedValue } from 'react-native-reanimated';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions, AppState } from 'react-native';
|
||||
import { useCameraDevice, type CameraPhotoOutput } from 'react-native-vision-camera';
|
||||
import { SkiaCamera, type SkiaCameraRef } from 'react-native-vision-camera-skia';
|
||||
import { createSynchronizable } from 'react-native-worklets';
|
||||
import {
|
||||
Canvas,
|
||||
useImage,
|
||||
@@ -14,9 +15,15 @@ import {
|
||||
Rect,
|
||||
ColorMatrix,
|
||||
Image as SkiaImage,
|
||||
ImageShader,
|
||||
TileMode,
|
||||
FilterMode,
|
||||
MipmapMode,
|
||||
} from '@shopify/react-native-skia';
|
||||
import { Lock } from 'lucide-react-native';
|
||||
import { Recipe, GPSInfo, FrameId } from '../types';
|
||||
import { getSkiaColorMatrix } from '../utils/colorUtils';
|
||||
import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils';
|
||||
import { TONE_SKSL, getToneUniforms } from '../utils/toneShader';
|
||||
import { formatCoordinate } from '../utils/locationUtils';
|
||||
|
||||
interface ViewfinderProps {
|
||||
@@ -28,13 +35,9 @@ interface ViewfinderProps {
|
||||
libraryImageUri: string | null;
|
||||
cameraPermissionGranted: boolean;
|
||||
onRequestCameraPermission: () => void;
|
||||
cameraRef?: React.RefObject<Camera | null>;
|
||||
photoOutput: CameraPhotoOutput;
|
||||
}
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
const viewfinderWidth = screenWidth - 32;
|
||||
const viewfinderHeight = (viewfinderWidth * 4) / 3;
|
||||
|
||||
const noiseEffect = Skia.RuntimeEffect.Make(`
|
||||
vec4 main(vec2 pos) {
|
||||
float r = fract(sin(dot(pos.xy, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
@@ -42,7 +45,26 @@ const noiseEffect = Skia.RuntimeEffect.Make(`
|
||||
}
|
||||
`);
|
||||
|
||||
const containerStyle = { width: viewfinderWidth, height: viewfinderHeight };
|
||||
// Tone runtime effect (DR/Highlight/Shadow) for the live camera preview worklet.
|
||||
// Hoisted at module scope so the worklet runtime can capture it — same pattern
|
||||
// as the official vision-camera-skia shader examples. Skia caches compiled
|
||||
// effects by source string, so Make here runs once per app launch.
|
||||
// Compile failure at module scope throws (JavascriptException → SIGABRT), so
|
||||
// swallow it into null — callers already fall back to matrix-only.
|
||||
const toneEffect = (() => {
|
||||
try {
|
||||
return Skia.RuntimeEffect.Make(TONE_SKSL);
|
||||
} catch (e) {
|
||||
console.error('[camtone] TONE_SKSL compile failed: ' + e);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
// Plain numeric primitives (captured by value into the preview worklet — the
|
||||
// worklet runtime can't read RNSkia's enum namespace objects directly).
|
||||
const T_CLAMP = TileMode.Clamp;
|
||||
const F_LINEAR = FilterMode.Linear;
|
||||
const M_NONE = MipmapMode.None;
|
||||
|
||||
export default function Viewfinder({
|
||||
mode,
|
||||
@@ -53,85 +75,334 @@ export default function Viewfinder({
|
||||
libraryImageUri,
|
||||
cameraPermissionGranted,
|
||||
onRequestCameraPermission,
|
||||
cameraRef,
|
||||
photoOutput,
|
||||
}: ViewfinderProps) {
|
||||
const hookedDevice = useCameraDevice('back');
|
||||
// VisionCamera fetches the device list once when this component mounts. When the
|
||||
// camera permission is granted *after* mount (first-run flow: GRANT button, then
|
||||
// OS dialog), that list stays empty until remount/restart, so the camera would
|
||||
// report "not available". Poll the native list briefly after permission is
|
||||
// granted and prefer a freshly found device.
|
||||
const [grantedDevice, setGrantedDevice] = useState<CameraDevice | undefined>();
|
||||
useEffect(() => {
|
||||
if (!cameraPermissionGranted) return;
|
||||
let stop = false;
|
||||
const iv = setInterval(() => {
|
||||
const back = Camera.getAvailableCameraDevices().find((d) => d.position === 'back');
|
||||
if (back && !stop) {
|
||||
setGrantedDevice(back);
|
||||
clearInterval(iv);
|
||||
clearTimeout(to);
|
||||
}
|
||||
}, 250);
|
||||
const to = setTimeout(() => clearInterval(iv), 6000);
|
||||
return () => {
|
||||
stop = true;
|
||||
clearInterval(iv);
|
||||
clearTimeout(to);
|
||||
};
|
||||
}, [cameraPermissionGranted]);
|
||||
const device = grantedDevice ?? hookedDevice;
|
||||
// Fullscreen hero: the viewfinder fills the entire screen, every overlay
|
||||
// rect derives from window size.
|
||||
const { width: vw, height: vh } = useWindowDimensions();
|
||||
|
||||
// useCameraDevice resolves the back camera fresh on every render, so once the
|
||||
// permission lands (first-run flow: GRANT button → OS dialog) a re-render is
|
||||
// enough to pick the device up — no manual polling needed.
|
||||
const device = useCameraDevice('back');
|
||||
|
||||
const skiaImage = useImage(libraryImageUri || '');
|
||||
const customFont = useFont(
|
||||
require('../../assets/CourierPrime-Regular.ttf'),
|
||||
Math.round(viewfinderWidth * 0.032)
|
||||
Math.round(vw * 0.032)
|
||||
);
|
||||
|
||||
const adjustments = recipe.adjustments;
|
||||
const colorMatrix = getSkiaColorMatrix(
|
||||
recipe.baseFilter,
|
||||
adjustments.exposure,
|
||||
adjustments.contrast,
|
||||
adjustments.saturation,
|
||||
adjustments.temperature,
|
||||
adjustments.tint,
|
||||
adjustments.colorChrome
|
||||
// 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);
|
||||
|
||||
// The Skia onFrame worklet runs on a separate runtime — keep the latest color
|
||||
// matrix in a cross-thread synchronizable value instead of a Reanimated
|
||||
// SharedValue (which lives on the UI runtime).
|
||||
const colorMatrixSync = useMemo(
|
||||
() => createSynchronizable<number[]>(colorMatrix),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
// Tone uniforms [dr, hl, sh] for the preview worklet; all-zero = no tone pass.
|
||||
const toneParams = (() => {
|
||||
const u = getToneUniforms(adjustments);
|
||||
return [u.dr, u.hl, u.sh];
|
||||
})();
|
||||
const toneSync = useMemo(
|
||||
() => createSynchronizable<number[]>([0, 0, 0]),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
colorMatrixSync.setBlocking(colorMatrix);
|
||||
toneSync.setBlocking(toneParams);
|
||||
}, [colorMatrix, toneParams, colorMatrixSync, toneSync]);
|
||||
|
||||
// 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.
|
||||
const skiaCameraRef = useRef<SkiaCameraRef>(null);
|
||||
const [cameraActive, setCameraActive] = useState(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');
|
||||
useEffect(() => {
|
||||
const sub = AppState.addEventListener('change', (s) => setAppActive(s === 'active'));
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
// Tap-to-focus / AE-AF-AWB lock point (view coords). null = auto continuous.
|
||||
const [focusPoint, setFocusPoint] = useState<{ x: number; y: number } | null>(null);
|
||||
const controller = cameraActive ? skiaCameraRef.current?.controller : undefined;
|
||||
const evStops = adjustments.exposureCompensation ?? 0;
|
||||
// Latest slider value, so stale retries from a previous value back off when
|
||||
// the user has already dragged further (dragging fast used to queue 5 retries
|
||||
// per intermediate value — each one cancelled by the next, spamming [ev]).
|
||||
const evStopsRef = useRef(evStops);
|
||||
evStopsRef.current = evStops;
|
||||
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`);
|
||||
}
|
||||
}, [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.
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
let attempt = 0;
|
||||
const trySet = () => {
|
||||
if (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));
|
||||
}
|
||||
});
|
||||
};
|
||||
trySet();
|
||||
return () => timers.forEach((t) => clearTimeout(t));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controller, evStops]);
|
||||
|
||||
// Tap-to-focus: lock AE/AF/AWB onto the tapped region (SceneAdaptiveness
|
||||
// 'locked' + autoResetAfter null = stays locked until another tap or unlock).
|
||||
// CameraX AE otherwise keeps re-metering the scene and cancels out the tone
|
||||
// preview (lift highlights -> AE underexposes -> looks unchanged), which is
|
||||
// why HL/SH edits read as "no effect" on the live preview.
|
||||
const handleTapFocus = async (x: number, y: number) => {
|
||||
if (!cameraActive) return;
|
||||
const cam = skiaCameraRef.current;
|
||||
const d = cam?.controller?.device;
|
||||
if (!d) return;
|
||||
if (!d.supportsFocusMetering && !d.supportsExposureMetering && !d.supportsWhiteBalanceMetering) {
|
||||
console.error('[focus] metering unsupported on this device');
|
||||
return;
|
||||
}
|
||||
setFocusPoint({ x, y });
|
||||
try {
|
||||
await cam.focusTo(
|
||||
{ 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));
|
||||
}
|
||||
};
|
||||
const handleUnlockFocus = async () => {
|
||||
const cam = skiaCameraRef.current;
|
||||
if (!cam) return;
|
||||
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));
|
||||
}
|
||||
};
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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), []);
|
||||
const handleFrame = useCallback<SkiaCameraOnFrame>(
|
||||
(frame, render) => {
|
||||
'worklet';
|
||||
render(({ canvas, frameTexture }) => {
|
||||
const paint = Skia.Paint();
|
||||
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSync.getBlocking()));
|
||||
const tone = toneSync.getBlocking();
|
||||
const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0);
|
||||
if (hasTone) {
|
||||
try {
|
||||
const imageShader = frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE);
|
||||
const shader = toneEffect.makeShaderWithChildren(tone, [imageShader]);
|
||||
if (shader != null) {
|
||||
// drawRect, not drawImage: paint shaders only apply to geometry
|
||||
// fills — drawImage ignores the paint shader, which is why the
|
||||
// DR/Highlight/Shadow tone never showed on the live preview.
|
||||
const w = frameTexture.width();
|
||||
const h = frameTexture.height();
|
||||
paint.setShader(shader);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, w, h), paint);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
if (!toneFailLogged.getBlocking()) {
|
||||
toneFailLogged.setBlocking(true);
|
||||
console.error('[camtone] shader failed, matrix-only: ' + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
canvas.drawImage(frameTexture, 0, 0, paint);
|
||||
});
|
||||
frame.dispose();
|
||||
},
|
||||
[colorMatrixSync, toneSync, toneFailLogged]
|
||||
);
|
||||
|
||||
const grainOpacity = adjustments.grain / 20;
|
||||
|
||||
const colorMatrixSV = useSharedValue(colorMatrix);
|
||||
// Library-still viewer: pinch-zoom (2 fingers) + double-tap zoom at the tap
|
||||
// point / double-tap again to reset to 100%. Transform is a Skia matrix
|
||||
// P' = scale*P + (tx,ty); gesture math keeps the focal point stationary.
|
||||
const [libZoom, setLibZoom] = useState({ s: 1, tx: 0, ty: 0 });
|
||||
const pinchRef = useRef<{ d: number; s: number; tx: number; ty: number; fx: number; fy: number } | null>(null);
|
||||
const pinchUsedRef = useRef(false);
|
||||
const lastTapRef = useRef<{ x: number; y: number; t: number } | null>(null);
|
||||
const clampZoom = (s: number) => Math.max(1, Math.min(6, s));
|
||||
// Contain-fit rect of the loaded photo in canvas coordinates (image is
|
||||
// centered with black letterbox above/below on a portrait screen).
|
||||
const imageFitRect = useMemo(() => {
|
||||
const img = skiaImage;
|
||||
if (!img) return null;
|
||||
const w = img.width();
|
||||
const h = img.height();
|
||||
if (!(w > 0 && h > 0)) return null;
|
||||
const s = Math.min(vw / w, vh / h);
|
||||
const dw = w * s;
|
||||
const dh = h * s;
|
||||
return { dx: (vw - dw) / 2, dy: (vh - dh) / 2, dw, dh };
|
||||
}, [skiaImage, vw, vh]);
|
||||
const onLibTouchStart = (e: any) => {
|
||||
const ts = e.nativeEvent.touches;
|
||||
if (ts.length >= 2) {
|
||||
const [a, b] = ts;
|
||||
pinchUsedRef.current = true;
|
||||
lastTapRef.current = null;
|
||||
pinchRef.current = {
|
||||
d: Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY),
|
||||
s: libZoom.s,
|
||||
tx: libZoom.tx,
|
||||
ty: libZoom.ty,
|
||||
fx: (a.locationX + b.locationX) / 2,
|
||||
fy: (a.locationY + b.locationY) / 2,
|
||||
};
|
||||
}
|
||||
};
|
||||
const onLibTouchMove = (e: any) => {
|
||||
const ts = e.nativeEvent.touches;
|
||||
let p = pinchRef.current;
|
||||
if (ts.length >= 2 && !p) {
|
||||
// Responder grant fires once on the first finger; a second finger that
|
||||
// lands later only produces move events, so seed the pinch baseline here.
|
||||
const [a, b] = ts;
|
||||
pinchUsedRef.current = true;
|
||||
lastTapRef.current = null;
|
||||
p = pinchRef.current = {
|
||||
d: Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY),
|
||||
s: libZoom.s,
|
||||
tx: libZoom.tx,
|
||||
ty: libZoom.ty,
|
||||
fx: (a.locationX + b.locationX) / 2,
|
||||
fy: (a.locationY + b.locationY) / 2,
|
||||
};
|
||||
}
|
||||
if (ts.length >= 2 && p) {
|
||||
const [a, b] = ts;
|
||||
const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY);
|
||||
const s = clampZoom((p.s * d) / Math.max(p.d, 1));
|
||||
const k = s / p.s;
|
||||
setLibZoom({
|
||||
s,
|
||||
tx: p.fx - k * (p.fx - p.tx),
|
||||
ty: p.fy - k * (p.fy - p.ty),
|
||||
});
|
||||
}
|
||||
};
|
||||
const onLibTouchEnd = (e: any) => {
|
||||
const wasPinch = pinchUsedRef.current;
|
||||
pinchRef.current = null;
|
||||
pinchUsedRef.current = false;
|
||||
if (wasPinch) return; // a double-tap is always a single finger
|
||||
const x = e.nativeEvent.locationX;
|
||||
const y = e.nativeEvent.locationY;
|
||||
const now = Date.now();
|
||||
const prev = lastTapRef.current;
|
||||
lastTapRef.current = { x, y, t: now };
|
||||
if (prev && now - prev.t < 350 && Math.hypot(x - prev.x, y - prev.y) < 50) {
|
||||
lastTapRef.current = null;
|
||||
setLibZoom((z) => {
|
||||
if (z.s > 1.01) return { s: 1, tx: 0, ty: 0 }; // double-tap again -> 100%
|
||||
// Clamp the focal point into the photo's contain rect so a double-tap
|
||||
// that lands in the black letterbox cannot fling the photo away.
|
||||
let fx = x;
|
||||
let fy = y;
|
||||
const fit = imageFitRect;
|
||||
if (fit) {
|
||||
fx = Math.min(Math.max(fx, fit.dx), fit.dx + fit.dw);
|
||||
fy = Math.min(Math.max(fy, fit.dy), fit.dy + fit.dh);
|
||||
}
|
||||
const s = 2.5;
|
||||
return { s, tx: fx - s * fx, ty: fy - s * fy }; // zoom into the tapped point
|
||||
});
|
||||
}
|
||||
};
|
||||
// Skia affine 3x3, row-major per SkMatrix getMatrix():
|
||||
// [s, 0, tx] x' = s*x + tx
|
||||
// [0, s, ty] y' = s*y + ty
|
||||
// [0, 0, 1]
|
||||
// Deliberately NOT built via Skia.Matrix().translate().scale(): on the
|
||||
// native Android side both are pre-concats (M = T·M then M = S·M) so the
|
||||
// final matrix is S·T and the translation gets scaled (P' = s·(P + t)) —
|
||||
// zooming then flings the photo up/left instead of holding the focal point.
|
||||
const libZoomMatrix = useMemo(
|
||||
() => [libZoom.s, 0, libZoom.tx, 0, libZoom.s, libZoom.ty, 0, 0, 1],
|
||||
[libZoom]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
colorMatrixSV.value = getSkiaColorMatrix(
|
||||
recipe.baseFilter,
|
||||
adjustments.exposure,
|
||||
adjustments.contrast,
|
||||
adjustments.saturation,
|
||||
adjustments.temperature,
|
||||
adjustments.tint,
|
||||
adjustments.colorChrome
|
||||
);
|
||||
}, [
|
||||
recipe.baseFilter,
|
||||
adjustments.exposure,
|
||||
adjustments.contrast,
|
||||
adjustments.saturation,
|
||||
adjustments.temperature,
|
||||
adjustments.tint,
|
||||
adjustments.colorChrome,
|
||||
]);
|
||||
|
||||
const frameProcessor = useSkiaFrameProcessor((frame) => {
|
||||
'worklet';
|
||||
const paint = Skia.Paint();
|
||||
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSV.value));
|
||||
frame.render(paint);
|
||||
}, [colorMatrixSV]);
|
||||
|
||||
const borderWidth = Math.min(viewfinderWidth, viewfinderHeight) * 0.05;
|
||||
const sideBorder = Math.min(viewfinderWidth, viewfinderHeight) * 0.06;
|
||||
const bottomBorder = viewfinderHeight * 0.18;
|
||||
const barHeight = viewfinderHeight * 0.12;
|
||||
const borderWidth = Math.min(vw, vh) * 0.05;
|
||||
const sideBorder = Math.min(vw, vh) * 0.06;
|
||||
const bottomBorder = vh * 0.18;
|
||||
const barHeight = vh * 0.12;
|
||||
|
||||
const renderFrameOverlay = () => {
|
||||
if (selectedFrame === 'none') return null;
|
||||
@@ -139,10 +410,10 @@ export default function Viewfinder({
|
||||
if (selectedFrame === 'classic-white') {
|
||||
return (
|
||||
<Group color="white">
|
||||
<Rect x={0} y={0} width={viewfinderWidth} height={borderWidth} />
|
||||
<Rect x={0} y={viewfinderHeight - borderWidth} width={viewfinderWidth} height={borderWidth} />
|
||||
<Rect x={0} y={0} width={borderWidth} height={viewfinderHeight} />
|
||||
<Rect x={viewfinderWidth - borderWidth} y={0} width={borderWidth} height={viewfinderHeight} />
|
||||
<Rect x={0} y={0} width={vw} height={borderWidth} />
|
||||
<Rect x={0} y={vh - borderWidth} width={vw} height={borderWidth} />
|
||||
<Rect x={0} y={0} width={borderWidth} height={vh} />
|
||||
<Rect x={vw - borderWidth} y={0} width={borderWidth} height={vh} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -150,15 +421,15 @@ export default function Viewfinder({
|
||||
if (selectedFrame === 'polaroid') {
|
||||
return (
|
||||
<Group>
|
||||
<Rect x={0} y={0} width={viewfinderWidth} height={sideBorder} color="#faf9f6" />
|
||||
<Rect x={0} y={0} width={sideBorder} height={viewfinderHeight} color="#faf9f6" />
|
||||
<Rect x={viewfinderWidth - sideBorder} y={0} width={sideBorder} height={viewfinderHeight} color="#faf9f6" />
|
||||
<Rect x={0} y={viewfinderHeight - bottomBorder} width={viewfinderWidth} height={bottomBorder} color="#faf9f6" />
|
||||
<Rect x={0} y={0} width={vw} height={sideBorder} color="#faf9f6" />
|
||||
<Rect x={0} y={0} width={sideBorder} height={vh} color="#faf9f6" />
|
||||
<Rect x={vw - sideBorder} y={0} width={sideBorder} height={vh} color="#faf9f6" />
|
||||
<Rect x={0} y={vh - bottomBorder} width={vw} height={bottomBorder} color="#faf9f6" />
|
||||
<Rect
|
||||
x={sideBorder - 1}
|
||||
y={sideBorder - 1}
|
||||
width={viewfinderWidth - 2 * sideBorder + 2}
|
||||
height={viewfinderHeight - sideBorder - bottomBorder + 2}
|
||||
width={vw - 2 * sideBorder + 2}
|
||||
height={vh - sideBorder - bottomBorder + 2}
|
||||
color="#e5e5e5"
|
||||
style="stroke"
|
||||
strokeWidth={1}
|
||||
@@ -170,8 +441,8 @@ export default function Viewfinder({
|
||||
if (selectedFrame === 'cinematic') {
|
||||
return (
|
||||
<Group color="black">
|
||||
<Rect x={0} y={0} width={viewfinderWidth} height={barHeight} />
|
||||
<Rect x={0} y={viewfinderHeight - barHeight} width={viewfinderWidth} height={barHeight} />
|
||||
<Rect x={0} y={0} width={vw} height={barHeight} />
|
||||
<Rect x={0} y={vh - barHeight} width={vw} height={barHeight} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -186,8 +457,6 @@ export default function Viewfinder({
|
||||
const lonStr = formatCoordinate(gpsInfo.longitude, 'lon');
|
||||
const locationName = gpsInfo.locality || 'STREET VIEW';
|
||||
const timestampStr = new Date(gpsInfo.timestamp).toLocaleDateString('vi-VN');
|
||||
// Only tag the provenance when we know it (photos from the OS camera/library);
|
||||
// old saved sessions without a source keep the original retro caption.
|
||||
const sourceTag =
|
||||
gpsInfo.source === 'exif'
|
||||
? 'EXIF'
|
||||
@@ -198,8 +467,10 @@ export default function Viewfinder({
|
||||
: null;
|
||||
const captionSuffix = sourceTag ? ` | ${sourceTag}` : ' | SS 1/125 f/2.8';
|
||||
|
||||
const yOffset = viewfinderHeight - (selectedFrame === 'polaroid' ? viewfinderHeight * 0.15 : 24);
|
||||
const xOffset = selectedFrame === 'polaroid' ? viewfinderWidth * 0.08 : 16;
|
||||
// Baseline above the bottom chrome (shutter rail) so the watermark stays
|
||||
// readable; polaroid frames push it further up onto the white area edge.
|
||||
const yOffset = vh - (selectedFrame === 'polaroid' ? vh * 0.18 + 24 : 150);
|
||||
const xOffset = selectedFrame === 'polaroid' ? vw * 0.08 : 16;
|
||||
|
||||
return (
|
||||
<Group>
|
||||
@@ -215,7 +486,7 @@ export default function Viewfinder({
|
||||
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
<Group blendMode="overlay">
|
||||
<Rect x={0} y={0} width={viewfinderWidth} height={viewfinderHeight} opacity={grainOpacity}>
|
||||
<Rect x={0} y={0} width={vw} height={vh} opacity={grainOpacity}>
|
||||
<Shader source={noiseEffect} />
|
||||
</Rect>
|
||||
</Group>
|
||||
@@ -232,20 +503,13 @@ export default function Viewfinder({
|
||||
<View className="h-[1px] w-full bg-white mt-[33%]" />
|
||||
<View className="h-[1px] w-full bg-white mb-[33%]" />
|
||||
</View>
|
||||
|
||||
<View className="absolute bottom-2 right-2 bg-black/60 rounded-full px-2 py-0.5">
|
||||
<Text className="text-[9px] text-amber-500 font-mono">LIVE</Text>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
if (mode === 'camera') {
|
||||
if (!cameraPermissionGranted) {
|
||||
return (
|
||||
<View
|
||||
style={containerStyle}
|
||||
className="bg-zinc-950 border border-zinc-800 rounded-2xl flex items-center justify-center p-6"
|
||||
>
|
||||
<View className="flex-1 bg-black items-center justify-center p-8">
|
||||
<Text className="text-zinc-400 font-mono text-center mb-4">
|
||||
Camera permission is required to use the viewfinder.
|
||||
</Text>
|
||||
@@ -262,10 +526,7 @@ export default function Viewfinder({
|
||||
|
||||
if (!device) {
|
||||
return (
|
||||
<View
|
||||
style={containerStyle}
|
||||
className="bg-zinc-950 border border-zinc-800 rounded-2xl items-center justify-center p-6"
|
||||
>
|
||||
<View className="flex-1 bg-black items-center justify-center p-8">
|
||||
<Text className="text-zinc-500 font-mono text-center text-xs">
|
||||
Camera not available on this device
|
||||
</Text>
|
||||
@@ -274,51 +535,109 @@ export default function Viewfinder({
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={containerStyle}
|
||||
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-black"
|
||||
>
|
||||
<Camera
|
||||
ref={cameraRef}
|
||||
<View className="flex-1 relative overflow-hidden bg-black">
|
||||
<SkiaCamera
|
||||
ref={skiaCameraRef}
|
||||
style={StyleSheet.absoluteFill}
|
||||
device={device}
|
||||
isActive={true}
|
||||
// Android: Skia frame processor crashes on devices w/o hardware-buffered
|
||||
// ImageAnalysis frames (e.g. Xiaomi 14 Ultra). Keep raw preview there;
|
||||
// filters still apply on capture/export. iOS keeps live filtered preview.
|
||||
frameProcessor={Platform.OS === 'android' ? undefined : frameProcessor}
|
||||
isActive={appActive}
|
||||
outputs={[photoOutput]}
|
||||
pixelFormat="yuv"
|
||||
onStarted={() => setCameraActive(true)}
|
||||
onFrame={handleFrame}
|
||||
/>
|
||||
{/* Tap-to-focus layer: fullscreen responder (all visual overlays above
|
||||
are pointerEvents none). locationX/Y are relative to this view. */}
|
||||
<View
|
||||
className="absolute inset-0"
|
||||
onStartShouldSetResponder={() => true}
|
||||
onResponderGrant={(e) => handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY)}
|
||||
/>
|
||||
{focusPoint && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
className="absolute"
|
||||
style={{ left: focusPoint.x - 26, top: focusPoint.y - 26 }}
|
||||
>
|
||||
<View className="w-[52px] h-[52px] rounded-full border-[1.5px] border-amber-400" />
|
||||
<View className="absolute left-1/2 top-1/2 -ml-px -mt-px w-[2px] h-[2px] rounded-full bg-amber-400" />
|
||||
</View>
|
||||
)}
|
||||
{focusPoint && (
|
||||
<TouchableOpacity
|
||||
onPress={handleUnlockFocus}
|
||||
className="absolute right-4 top-24 bg-black/70 border border-amber-400/70 rounded-full px-3 py-1.5 flex-row items-center space-x-1.5"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Lock size={11} color="#f59e0b" />
|
||||
<Text className="text-amber-400 font-mono text-[10px] font-bold">AE/AF LOCKED — TAP TO UNLOCK</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{renderCameraOverlays()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Library mode
|
||||
// Library mode — fullscreen, full-photo (contain), pinch/double-tap zoom.
|
||||
const toneOn = toneParams[0] !== 0 || toneParams[1] !== 0 || toneParams[2] !== 0;
|
||||
return (
|
||||
<View
|
||||
style={containerStyle}
|
||||
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950 justify-center items-center"
|
||||
>
|
||||
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
|
||||
{libraryImageUri && skiaImage ? (
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
<Group>
|
||||
<ColorMatrix matrix={colorMatrix} />
|
||||
<SkiaImage
|
||||
image={skiaImage}
|
||||
fit="cover"
|
||||
rect={rect(0, 0, viewfinderWidth, viewfinderHeight)}
|
||||
/>
|
||||
</Group>
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
<Group blendMode="overlay">
|
||||
<Rect x={0} y={0} width={viewfinderWidth} height={viewfinderHeight} opacity={grainOpacity}>
|
||||
<Shader source={noiseEffect} />
|
||||
</Rect>
|
||||
<>
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
<Group matrix={libZoomMatrix}>
|
||||
<ColorMatrix matrix={colorMatrix} />
|
||||
{toneEffect && toneOn && imageFitRect ? (
|
||||
// DR/Highlight/Shadow tone curve. The Rect must cover exactly
|
||||
// the contain-fitted photo area (not the full canvas): a
|
||||
// fullscreen ImageShader with fit="contain" clamps/smears the
|
||||
// photo edges across the black letterbox (user-visible as "the
|
||||
// background is a zoomed copy of the photo").
|
||||
<Rect x={imageFitRect.dx} y={imageFitRect.dy} width={imageFitRect.dw} height={imageFitRect.dh}>
|
||||
<Shader
|
||||
source={toneEffect}
|
||||
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
|
||||
>
|
||||
<ImageShader
|
||||
image={skiaImage}
|
||||
fit="fill"
|
||||
tx="clamp"
|
||||
ty="clamp"
|
||||
rect={rect(imageFitRect.dx, imageFitRect.dy, imageFitRect.dw, imageFitRect.dh)}
|
||||
/>
|
||||
</Shader>
|
||||
</Rect>
|
||||
) : (
|
||||
<SkiaImage
|
||||
image={skiaImage}
|
||||
fit="contain"
|
||||
rect={rect(0, 0, vw, vh)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{renderFrameOverlay()}
|
||||
{renderGPSWatermark()}
|
||||
</Canvas>
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
<Group blendMode="overlay">
|
||||
<Rect x={0} y={0} width={vw} height={vh} opacity={grainOpacity}>
|
||||
<Shader source={noiseEffect} />
|
||||
</Rect>
|
||||
</Group>
|
||||
)}
|
||||
{/* Frame + GPS watermark belong to the photo → zoom with it. Grain
|
||||
is a fullscreen screen effect → stays fixed above. */}
|
||||
<Group matrix={libZoomMatrix}>
|
||||
{renderFrameOverlay()}
|
||||
{renderGPSWatermark()}
|
||||
</Group>
|
||||
</Canvas>
|
||||
<View
|
||||
className="absolute inset-0"
|
||||
onStartShouldSetResponder={() => true}
|
||||
onMoveShouldSetResponder={() => true}
|
||||
onResponderGrant={onLibTouchStart}
|
||||
onResponderMove={onLibTouchMove}
|
||||
onResponderRelease={onLibTouchEnd}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<View className="items-center justify-center p-6">
|
||||
<Text className="text-zinc-500 font-mono text-center text-xs">
|
||||
|
||||
Reference in New Issue
Block a user