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(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([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(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[] = []; 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['onFrame']>; const toneFailLogged = useMemo(() => createSynchronizable(false), []); const handleFrame = useCallback( (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 ( ); } if (selectedFrame === 'polaroid') { return ( ); } if (selectedFrame === 'cinematic') { return ( ); } 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 ( ); }; const renderCameraOverlays = () => ( <> {grainOpacity > 0 && noiseEffect && ( )} {renderFrameOverlay()} {renderGPSWatermark()} ); if (mode === 'camera') { if (!cameraPermissionGranted) { return ( Camera permission is required to use the viewfinder. GRANT PERMISSION ); } if (!device) { return ( Camera not available on this device ); } return ( setCameraActive(true)} onFrame={handleFrame} /> {/* Tap-to-focus layer: fullscreen responder (all visual overlays above are pointerEvents none). locationX/Y are relative to this view. */} true} onResponderGrant={(e) => handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY)} /> {focusPoint && ( )} {focusPoint && ( AE/AF LOCKED — TAP TO UNLOCK )} {renderCameraOverlays()} ); } // Library mode — fullscreen, full-photo (contain), pinch/double-tap zoom. const toneOn = toneParams[0] !== 0 || toneParams[1] !== 0 || toneParams[2] !== 0; return ( {libraryImageUri && skiaImage ? ( <> {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"). ) : ( )} {grainOpacity > 0 && noiseEffect && ( )} {/* Frame + GPS watermark belong to the photo → zoom with it. Grain is a fullscreen screen effect → stays fixed above. */} {renderFrameOverlay()} {renderGPSWatermark()} true} onMoveShouldSetResponder={() => true} onResponderGrant={onLibTouchStart} onResponderMove={onLibTouchMove} onResponderRelease={onLibTouchEnd} /> ) : ( {libraryImageUri ? 'LOADING IMAGE...' : 'NO IMAGE SELECTED'} Tap the gallery icon below to load a photo )} ); }