654 lines
26 KiB
TypeScript
654 lines
26 KiB
TypeScript
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,
|
|
useFont,
|
|
Text as SkiaText,
|
|
rect,
|
|
Shader,
|
|
Skia,
|
|
Group,
|
|
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, applyExposureGain } from '../utils/colorUtils';
|
|
import { TONE_SKSL, getToneUniforms } from '../utils/toneShader';
|
|
import { formatCoordinate } from '../utils/locationUtils';
|
|
|
|
interface ViewfinderProps {
|
|
mode: 'camera' | 'library';
|
|
recipe: Recipe;
|
|
selectedFrame: FrameId;
|
|
useGeotag: boolean;
|
|
gpsInfo: GPSInfo | null;
|
|
libraryImageUri: string | null;
|
|
cameraPermissionGranted: boolean;
|
|
onRequestCameraPermission: () => void;
|
|
photoOutput: CameraPhotoOutput;
|
|
}
|
|
|
|
const noiseEffect = Skia.RuntimeEffect.Make(`
|
|
vec4 main(vec2 pos) {
|
|
float r = fract(sin(dot(pos.xy, vec2(12.9898, 78.233))) * 43758.5453);
|
|
return vec4(vec3(r), 1.0);
|
|
}
|
|
`);
|
|
|
|
// 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,
|
|
recipe,
|
|
selectedFrame,
|
|
useGeotag,
|
|
gpsInfo,
|
|
libraryImageUri,
|
|
cameraPermissionGranted,
|
|
onRequestCameraPermission,
|
|
photoOutput,
|
|
}: ViewfinderProps) {
|
|
// 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(vw * 0.032)
|
|
);
|
|
|
|
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);
|
|
|
|
// 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;
|
|
|
|
// 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]
|
|
);
|
|
|
|
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;
|
|
|
|
if (selectedFrame === 'classic-white') {
|
|
return (
|
|
<Group color="white">
|
|
<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>
|
|
);
|
|
}
|
|
|
|
if (selectedFrame === 'polaroid') {
|
|
return (
|
|
<Group>
|
|
<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={vw - 2 * sideBorder + 2}
|
|
height={vh - sideBorder - bottomBorder + 2}
|
|
color="#e5e5e5"
|
|
style="stroke"
|
|
strokeWidth={1}
|
|
/>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
if (selectedFrame === 'cinematic') {
|
|
return (
|
|
<Group color="black">
|
|
<Rect x={0} y={0} width={vw} height={barHeight} />
|
|
<Rect x={0} y={vh - barHeight} width={vw} height={barHeight} />
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const renderGPSWatermark = () => {
|
|
if (!useGeotag || !gpsInfo || !customFont) return null;
|
|
|
|
const latStr = formatCoordinate(gpsInfo.latitude, 'lat');
|
|
const lonStr = formatCoordinate(gpsInfo.longitude, 'lon');
|
|
const locationName = gpsInfo.locality || 'STREET VIEW';
|
|
const timestampStr = new Date(gpsInfo.timestamp).toLocaleDateString('vi-VN');
|
|
const sourceTag =
|
|
gpsInfo.source === 'exif'
|
|
? 'EXIF'
|
|
: gpsInfo.source === 'mediaLibrary'
|
|
? 'MEDIA LIB'
|
|
: gpsInfo.source === 'device'
|
|
? 'DEVICE GPS'
|
|
: null;
|
|
const captionSuffix = sourceTag ? ` | ${sourceTag}` : ' | SS 1/125 f/2.8';
|
|
|
|
// 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>
|
|
<SkiaText x={xOffset} y={yOffset - 32} text={`📍 ${locationName}`} font={customFont} color="#f59e0b" />
|
|
<SkiaText x={xOffset} y={yOffset - 16} text={`${latStr}, ${lonStr}`} font={customFont} color="#f59e0b" />
|
|
<SkiaText x={xOffset} y={yOffset} text={`${timestampStr}${captionSuffix}`} font={customFont} color="#f59e0b" />
|
|
</Group>
|
|
);
|
|
};
|
|
|
|
const renderCameraOverlays = () => (
|
|
<>
|
|
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
|
|
{grainOpacity > 0 && noiseEffect && (
|
|
<Group blendMode="overlay">
|
|
<Rect x={0} y={0} width={vw} height={vh} opacity={grainOpacity}>
|
|
<Shader source={noiseEffect} />
|
|
</Rect>
|
|
</Group>
|
|
)}
|
|
{renderFrameOverlay()}
|
|
{renderGPSWatermark()}
|
|
</Canvas>
|
|
|
|
<View className="absolute inset-0 flex-row justify-between pointer-events-none opacity-20">
|
|
<View className="w-[1px] h-full bg-white ml-[33%]" />
|
|
<View className="w-[1px] h-full bg-white mr-[33%]" />
|
|
</View>
|
|
<View className="absolute inset-0 flex-col justify-between pointer-events-none opacity-20">
|
|
<View className="h-[1px] w-full bg-white mt-[33%]" />
|
|
<View className="h-[1px] w-full bg-white mb-[33%]" />
|
|
</View>
|
|
</>
|
|
);
|
|
|
|
if (mode === 'camera') {
|
|
if (!cameraPermissionGranted) {
|
|
return (
|
|
<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>
|
|
<TouchableOpacity
|
|
onPress={onRequestCameraPermission}
|
|
className="bg-amber-500 px-6 py-3 rounded-full"
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text className="text-black font-mono text-xs font-bold">GRANT PERMISSION</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (!device) {
|
|
return (
|
|
<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>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View className="flex-1 relative overflow-hidden bg-black">
|
|
<SkiaCamera
|
|
ref={skiaCameraRef}
|
|
style={StyleSheet.absoluteFill}
|
|
device={device}
|
|
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 — fullscreen, full-photo (contain), pinch/double-tap zoom.
|
|
const toneOn = toneParams[0] !== 0 || toneParams[1] !== 0 || toneParams[2] !== 0;
|
|
return (
|
|
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
|
|
{libraryImageUri && skiaImage ? (
|
|
<>
|
|
<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>
|
|
{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">
|
|
{libraryImageUri ? 'LOADING IMAGE...' : 'NO IMAGE SELECTED'}
|
|
</Text>
|
|
<Text className="text-zinc-600 font-mono text-center text-[10px] mt-1">
|
|
Tap the gallery icon below to load a photo
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|