import React, { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions, AppState } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useCameraDevice, type CameraPhotoOutput } from 'react-native-vision-camera'; import { SkiaCamera, type SkiaCameraRef } from 'react-native-vision-camera-skia'; import { createSynchronizable } from 'react-native-worklets'; import { Canvas, ClipOp, 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, AspectRatio, 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'; import { polaroidLayout, wallframeLayout, WALLFRAME_PNG_W, WALLFRAME_PNG_H } from '../utils/frameUtils'; interface ViewfinderProps { mode: 'camera' | 'library'; recipe: Recipe; selectedFrame: FrameId; useGeotag: boolean; gpsInfo: GPSInfo | null; libraryImageUri: string | null; cameraPermissionGranted: boolean; onRequestCameraPermission: () => void; photoOutput: CameraPhotoOutput; // Composition ratio (FULL / 4:3 / 3:2 upright) driving the viewfinder band // and the photo-area crop. Wall frame ignores it (fixed frame opening). aspectRatio: AspectRatio; // RAW DNG toggle: while on, the DNG output below is attached to the session // (a second ImageCapture use case). Kept null-free at the App level; this is // nullable only because the session may not exist yet. rawEnabled: boolean; rawOutput: CameraPhotoOutput | null; // Session-level error from the camera (e.g. DNG output failed to bind). App // auto-disables RAW on such errors and alerts the user. onSessionError?: (error: Error) => void; // Quick exposure slider (shown while AE/AF is locked) — live AE bias in EV. onExposureChange?: (ev: number) => void; } 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; } })(); const cinemaEffect = (() => { try { return Skia.RuntimeEffect.Make(CINEMA_SKSL); } catch (e) { console.error('[cinema] CINEMA_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; const CLIP_INTERSECT = ClipOp.Intersect; export default function Viewfinder({ mode, recipe, selectedFrame, useGeotag, gpsInfo, libraryImageUri, cameraPermissionGranted, onRequestCameraPermission, photoOutput, aspectRatio, rawEnabled, rawOutput, onSessionError, onExposureChange, }: ViewfinderProps) { // Fullscreen hero: the viewfinder fills the entire screen, every overlay // rect derives from window size. const { width: vw, height: vh } = useWindowDimensions(); const insets = useSafeAreaInsets(); // Composition ratio band. With a ratio active the photo area is a centered // portrait strip (w/h = ratioWH) and everything outside is letterboxed; // exports crop to the same ratio. Only the plain full-picture frames use the // band — polaroid keeps its own card layout (window aspect follows the ratio) // and wall frame deliberately ignores the ratio entirely (its opening is a // fixed 2:3). When no band is active the region is just the full screen, so // every consumer below degrades to the old fullscreen behavior untouched. const ratioWH = aspectRatio === 'full' ? null : ASPECT_RATIO_W_H[aspectRatio]; const regionBand = ratioWH !== null && (selectedFrame === 'none' || selectedFrame === 'classic-white' || selectedFrame === 'cinematic'); const region = useMemo(() => { if (!regionBand || ratioWH === null) return { x: 0, y: 0, w: vw, h: vh }; const h = vw / ratioWH; if (h <= vh) { return { x: 0, y: (vh - h) / 2, w: vw, h }; } // Screen narrower than the ratio: fit by height, letterbox the sides. const w = vh * ratioWH; return { x: (vw - w) / 2, y: 0, w, h }; }, [regionBand, ratioWH, vw, vh]); // Camera outputs: attach the DNG output only while RAW is on — CameraX // cannot bind two ImageCapture use cases at once, and App's session-error // handler turns RAW off again when that ever fails. const cameraOutputs = useMemo( () => (rawEnabled && rawOutput ? [photoOutput, rawOutput] : [photoOutput]), [photoOutput, rawOutput, rawEnabled] ); // 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 || ''); // Wall-frame PNG artwork (project root). Loaded lazily so other frames pay // no asset cost; source switches to '' when unselected (useImage → null). const wallImage = useImage(selectedFrame === 'wallframe' ? require('../../wallframe.png') : ''); const customFont = useFont( require('../../assets/Cousine-Regular.ttf'), Math.round(vw * 0.032) ); // Monochrome Noto Emoji subset (📍 U+1F4CD, 📷 U+1F4F7) for the watermark // icons — glyphs painted with the same color as the text next to them. const iconFont = useFont( require('../../assets/NotoEmoji-GPS.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 [] ); // Cinema seasonal grade params (16 flat floats; all-zero = off). const cinemaParams = recipe.cinema ? getCinemaUniforms(recipe.cinema) : null; const cinemaSync = useMemo( () => createSynchronizable(cinemaOffFlat()), // eslint-disable-next-line react-hooks/exhaustive-deps [] ); useEffect(() => { colorMatrixSync.setBlocking(colorMatrix); toneSync.setBlocking(toneParams); cinemaSync.setBlocking(cinemaParams ? cinemaParams.flat : cinemaOffFlat()); }, [colorMatrix, toneParams, cinemaParams, colorMatrixSync, toneSync, cinemaSync]); // Exposure compensation → hardware AE bias. Passed imperatively (never as a // declarative prop): v5 calls setExposureBias while the session is still // starting up and throws "Camera is not active" — seen on the Xiaomi at boot. // The controller only exists after onStarted, so it is safe to bias then. // Deps on the controller identity (not just cameraActive): a session restart // swaps the controller and silently resets the bias to 0. 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. // The slider emits many values per drag: fire setExposureBias only after a // short quiet period, so a stream of EV changes collapses into one camera // call. Without this, each intermediate value queued its own retry chain // and the in-flight cancels (with AE re-lock on the Xiaomi) caused preview // flicker while dragging EV. const timers: ReturnType[] = []; let attempt = 0; let disposed = false; const trySet = () => { if (disposed || evStopsRef.current !== evStops) return; // stale — a newer drag is pending attempt += 1; controller .setExposureBias(clamped) .then(() => { console.error(`[ev] ev=${evStops} -> idx=${clamped} ok, read=${controller.exposureBias}`); }) .catch((e) => { console.error(`[ev] ev=${evStops} idx=${clamped} attempt=${attempt} failed: ${String(e).slice(0, 100)}`); if (attempt < 5 && String(e).includes('Cancel')) { timers.push(setTimeout(trySet, 150)); } }); }; const debounce = setTimeout(trySet, 110); return () => { disposed = true; clearTimeout(debounce); timers.forEach((t) => clearTimeout(t)); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [controller, evStops]); // 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)); } }; // Quick exposure slider — appears while AE/AF is locked. Drag up/down maps // directly to the camera AE bias in EV stops (same path as the recipe EV // slider: Viewfinder already syncs adjustments.exposureCompensation to the // controller via setExposureBias, and captures apply the bias on-device). const EV_RANGE = 3; // matches the ±24 index range at 1 index = 1/8 EV const evTrackTop = vh * 0.24; const evTrackH = vh * 0.42; 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); const evThumbY = evTrackTop + ((EV_RANGE - clampEv(evStops)) / (2 * EV_RANGE)) * evTrackH; const onEvSliderGrant = (e: any) => { evDragRef.current = { y0: e.nativeEvent.locationY, ev0: evStops }; }; const onEvSliderMove = (e: any) => { const g = evDragRef.current; if (!g || !onExposureChange) return; const dy = g.y0 - e.nativeEvent.locationY; // drag up = brighter const ev = clampEv(g.ev0 + dy / evPxPerStop); onExposureChange(Math.round(ev * 10) / 10); }; 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]); // 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), []); // 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 // redraws the feed cover-mapped INTO that rect instead of the default // fullscreen cover-fit — with a 4:3 ratio (== native sensor) the whole // sensor shows, narrower ratios crop exactly like the export. [0]=0 keeps // the legacy fullscreen draw untouched. const feedRectSync = useMemo(() => createSynchronizable([0, 0, 0, 0, 0, 0]), []); // RETRO POLAROID on the live camera is NOT drawn in the frame pipeline: that // canvas is an offscreen surface in frame-pixel space which RNSkia later // cover-fits onto the view, so frame-space geometry overflows the screen and // cannot be aligned with the dp-space overlays. The camera just renders the // plain fullscreen feed here; the polaroid card is composited by the JS // overlay (renderFrameOverlay) in view coordinates, like every other frame. 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 cinema = cinemaSync.getBlocking(); 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, // 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) or as a plain color-matrix image. const drawFullFrame = () => { if (hasTone || hasCinema) { try { const imageShader = frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE); let final = null; if (hasTone) { final = toneEffect.makeShaderWithChildren(tone, [imageShader]); } if (hasCinema) { const cinemaShader = cinemaEffect.makeShaderWithChildren(cinema, [final ?? imageShader]); if (cinemaShader != null) final = cinemaShader; } if (final != null) { const w = frameTexture.width(); const h = frameTexture.height(); paint.setShader(final); 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); }; // Composition band: without this the SkiaCamera layer later cover-fits // the upright snapshot onto the fullscreen view, so a 4:3 ratio band // only revealed a center slice of the sensor. Redraw the feed INTO the // band rect: black the surface, clip to the rect's raw-frame inverse // (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 bandActive = feed[0] > 0; if (!bandActive) { drawFullFrame(); return; } const ftW = frameTexture.width(); const ftH = frameTexture.height(); const orient = frame.orientation; const isLand = orient === 'left' || orient === 'right'; const ow = isLand ? ftH : ftW; const oh = isLand ? ftW : ftH; const ac = ow / oh; const viewAspect = feed[1]; if (!(ow > 0 && oh > 0 && ac > 0 && viewAspect > 0 && feed[4] > 0 && feed[5] > 0)) { drawFullFrame(); return; } // View -> upright-snapshot mapping: the snapshot is cover-fitted to the // view, so the view shows a centered visX x visY window of it. const visX = Math.min(1, viewAspect / ac); const visY = Math.min(1, ac / viewAspect); const ox0 = (1 - visX) / 2 + feed[2] * visX; const ox1 = ox0 + feed[4] * visX; const oy0 = (1 - visY) / 2 + feed[3] * visY; const oy1 = oy0 + feed[5] * visY; const X0 = ox0 * ow; const X1 = ox1 * ow; const Y0 = oy0 * oh; const Y1 = oy1 * oh; // Invert the renderToTexture CTM (out-space -> raw frame space) by // walking its concats in reverse: A translate(ow/2,oh/2), mirror // scale(-1,1), rotate(-orientation), D translate(-oh/2,-ow/2). const mirrored = frame.isMirrored === true; const deg = orient === 'down' ? 180 : orient === 'left' ? 90 : orient === 'right' ? 270 : 0; const toRaw = (X: number, Y: number): [number, number] => { let x = X - ow / 2; let y = Y - oh / 2; if (mirrored) x = -x; const r = (-deg * Math.PI) / 180; const c = Math.cos(r); const s = Math.sin(r); const rx = x * c - y * s; const ry = x * s + y * c; if (isLand) return [rx + oh / 2, ry + ow / 2]; return [rx + ow / 2, ry + oh / 2]; }; let u0 = ftW; let u1 = 0; let v0 = ftH; let v1 = 0; const c0 = toRaw(X0, Y0); const c1 = toRaw(X1, Y0); const c2 = toRaw(X0, Y1); const c3 = toRaw(X1, Y1); const cs = [c0, c1, c2, c3]; for (let i = 0; i < 4; i++) { u0 = Math.min(u0, cs[i][0]); u1 = Math.max(u1, cs[i][0]); v0 = Math.min(v0, cs[i][1]); v1 = Math.max(v1, cs[i][1]); } const rw = u1 - u0; const rh = v1 - v0; if (!(rw > 0 && rh > 0)) { drawFullFrame(); return; } // Cover-map the full sensor into the raw rect (dst preserves the sensor // aspect, centered; the clip below trims the overflow) — translate then // scale: Skia concat applies the later call first (p -> s*p + d). const s = Math.max(rw / ftW, rh / ftH); const dw = s * ftW; const dh = s * ftH; const dx = u0 + (rw - dw) / 2; const dy = v0 + (rh - dh) / 2; // Fresh paint; its default color is opaque black (Skia default), so // the surface fill below needs no explicit color set. const blackPaint = Skia.Paint(); canvas.drawRect(Skia.XYWHRect(0, 0, ftW, ftH), blackPaint); canvas.save(); canvas.clipRect(Skia.XYWHRect(u0, v0, rw, rh), CLIP_INTERSECT, false); canvas.translate(dx, dy); canvas.scale(s, s); drawFullFrame(); canvas.restore(); }); frame.dispose(); }, [colorMatrixSync, toneSync, cinemaSync, toneFailLogged, feedRectSync] ); 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]); // Polaroid print geometry (JS/overlay space) for the CURRENT mode. The card // always fills/holds to the screen container; the photo WINDOW keeps the // *composition* aspect: with a ratio active that ratio (window content is // center-cropped to it), otherwise the library photo's own aspect or the // camera's upright capture aspect (4:3 sensor → 3:4 portrait, NOT the screen // aspect: a screen-aspect window made the card nearly fullscreen and // pole-like, whereas the real print is a shorter 3:4 card letterboxed on // screen). ponytail: verified on Xiaomi quality-mode captures (4:3); if the // session negotiates 16:9 photos, change to 9/16. const polaroidAspect = ratioWH ?? (mode === 'library' && skiaImage && skiaImage.width() > 0 && skiaImage.height() > 0 ? skiaImage.width() / skiaImage.height() : 3 / 4); // Photo draw fit inside the polaroid window: cover when the window aspect // (ratio) differs from the source photo, fill otherwise (aspects match). const polaroidFit: 'cover' | 'fill' = ratioWH ? 'cover' : 'fill'; const polaroidGeo = useMemo(() => { if (selectedFrame !== 'polaroid') return null; return polaroidLayout(vw, vh, polaroidAspect, 'contain'); }, [selectedFrame, polaroidAspect, vw, vh]); // Wall-frame PNG geometry (JS/overlay space). Fixed landscape 3:2 window — // the PNG is contain-fitted like a physical frame; photo content behind it // is cover-cropped by the mat opening (never letterboxed inside the window). const wallGeo = useMemo(() => { if (selectedFrame !== 'wallframe') return null; return wallframeLayout(vw, vh, 'contain'); }, [selectedFrame, vw, vh]); // Live feed band rect for the CAMERA preview: the photo-area that must show // the export-equivalent sensor crop — the ratio band region (plain frames), // the polaroid photo window or the wall-frame window. When present the frame // worklet redraws the feed into it (native 4:3 shows the whole sensor); // when null ([0]=0) the feed stays the legacy fullscreen cover draw. const cameraPhotoRect = mode === 'camera' ? polaroidGeo ? polaroidGeo.window : selectedFrame === 'wallframe' && wallGeo && wallImage ? wallGeo.window : regionBand ? region : null : null; useEffect(() => { feedRectSync.setBlocking( cameraPhotoRect ? [1, vw / vh, cameraPhotoRect.x / vw, cameraPhotoRect.y / vh, cameraPhotoRect.w / vw, cameraPhotoRect.h / vh] : [0, 0, 0, 0, 0, 0] ); }, [cameraPhotoRect, vw, vh, feedRectSync]); 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 display rect (composition // region under a ratio band, contain rect otherwise) so a double-tap // that lands in the black letterbox cannot fling the photo away. let fx = x; let fy = y; const fit = regionBand ? { dx: region.x, dy: region.y, dw: region.w, dh: region.h } : 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] ); // Border/bars scale with the composition region so framed exports stay // proportional to the cropped photo (region == fullscreen when no ratio). const borderWidth = Math.min(region.w, region.h) * 0.05; const barHeight = region.h * 0.12; const renderFrameOverlay = () => { if (selectedFrame === 'none') return null; // Library polaroid is drawn by its own contain-fit canvas (see the early // return further down); the overlay here handles the frames that sit on top // of an already fullscreen picture. if (selectedFrame === 'polaroid') { const geo = polaroidGeo; if (!geo) return null; const { card, window: win } = geo; // Camera polaroid, composited in view (dp) coordinates: letterbox the // live feed black outside the card, paint the white card around the photo // window and a thin seam at the window edge. The window itself stays // transparent so the fullscreen feed shows through underneath — drawn // this way the card can never overflow the screen, and it lines up // exactly with the grain clip / GPS anchor (same polaroidGeo rects). const black = '#000000'; const white = '#faf9f6'; const seam = '#e5e5e5'; return ( {/* black letterbox bars around the card */} {/* white card area around the photo window */} {/* seam between photo window and card */} ); } if (selectedFrame === 'wallframe') { const geo = wallGeo; if (!geo || !wallImage) return null; // PNG still loading → bare feed const { frame, drawMatrix } = geo; // Letterbox the live feed black outside the frame rect, then draw the // artwork ROTATED into that rect: its transparent window shows the feed // (cover-cropped center, WYSIWYG with the export crop) and the mat covers // the rest of the screen — the feed never fills the whole display. const black = '#000000'; return ( ); } if (selectedFrame === 'classic-white') { // Border drawn on the composition region (region == fullscreen when no // ratio band), so it always sits at the photo's edge. return ( ); } if (selectedFrame === 'cinematic') { // Letterbox bars hug the composition region's top/bottom edges. return ( ); } return null; }; const renderGPSWatermark = () => { if (!useGeotag || !gpsInfo || !customFont || !iconFont) return null; const locationName = gpsInfo.locality || 'STREET VIEW'; const d = new Date(gpsInfo.timestamp); const pad = (n: number) => String(n).padStart(2, '0'); const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`; // Anchor to the PHOTO's top-left, not the screen. Bottom anchoring put the // mark under the floating chip rows (open WB/FRAME panel covered it), so it // now lives at the top of the photo/window: the bottom sheet can never reach // it. In camera mode the photo fills the screen; in library mode it is // contain-fitted with letterbox — use imageFitRect so the mark sits on the // photo corner. With a polaroid frame the photo lives inside the card // window — anchor to that. Always clear the floating TopBar (recipe name + // SAVE), which ends around insets.top + 56 dp. const win = polaroidGeo?.window ?? wallGeo?.window ?? null; const area = win ? { dx: win.x, dy: win.y, dw: win.w, dh: win.h } : regionBand ? { dx: region.x, dy: region.y, dw: region.w, dh: region.h } : mode === 'library' && imageFitRect ? imageFitRect : { dx: 0, dy: 0, dw: vw, dh: vh }; const iconSize = Math.round(vw * 0.032); // matches customFont/iconFont size const topBarBottom = insets.top + 56; const topBase = Math.max(area.dy + Math.round(iconSize * 0.6), topBarBottom); const xOffset = area.dx + (win ? area.dw * 0.05 : Math.min(16, area.dw * 0.04)); // Two stacked lines (pin/location above camera/timestamp). const row1Y = topBase + Math.round(iconSize * 1.1); const row2Y = topBase + Math.round(iconSize * 2.3); // One clear space (0.6em ≈ a mono space) between each icon and its text. const gap = (w: number) => Math.round(Math.max(w, iconSize) + iconSize * 0.6); const pinW = iconFont.measureText('📍'); const camW = iconFont.measureText('📷'); return ( {/* Icons white so they read as glyphs, not part of the amber text */} ); }; const renderCameraOverlays = () => { const framedWin = polaroidGeo?.window ?? wallGeo?.window ?? null; return ( <> {/* Aspect-ratio band: black out the live feed outside the composition region. Frames with their own geometry (polaroid/wall) draw their own bars later, so this only fires for the plain frames. */} {regionBand && ( )} {grainOpacity > 0 && noiseEffect && ( {/* Grain belongs to the photo; on a framed photo it must not spill onto the mat, so it is clipped to the photo window. */} )} {renderFrameOverlay()} {renderGPSWatermark()} {/* Rule-of-thirds guide, clipped to the composition region (fullscreen when no ratio band). */} ); }; 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} onError={onSessionError} /> {/* 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 )} {focusPoint && onExposureChange && ( <> {/* EV value readout (left of the slider) */} EV {(evStops > 0 ? '+' : '') + evStops.toFixed(1)} {/* Vertical track: drag up = brighter, drag down = darker */} true} onMoveShouldSetResponder={() => true} onResponderGrant={onEvSliderGrant} onResponderMove={onEvSliderMove} onResponderRelease={onEvSliderRelease} onResponderTerminate={onEvSliderRelease} > +3 -3 )} {renderCameraOverlays()} ); } // Library mode — fullscreen, full-photo (contain), pinch/double-tap zoom. const toneOn = toneParams[0] !== 0 || toneParams[1] !== 0 || toneParams[2] !== 0; const cinemaOn = cinemaEffect != null && !!cinemaParams; const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn); // Photo display rect + draw fit in the plain library viewer: with a ratio // band the photo is center-cropped (cover) into the composition region; // otherwise the full photo is contain-fitted to the screen (fill into its // own aspect rect — no distortion, identical to the old fullscreen contain). const dispRect = regionBand ? { dx: region.x, dy: region.y, dw: region.w, dh: region.h } : imageFitRect; const dispFit: 'cover' | 'fill' = regionBand ? 'cover' : 'fill'; // RETRO POLAROID (library): the whole picture is contain-fitted into the // card's photo window — nothing is masked or cropped, and the card is // centered on the black background like a physical print. The window keeps // the photo aspect ratio, so fit="fill" never distorts. if (mode === 'library' && polaroidGeo && skiaImage && libraryImageUri) { const win = polaroidGeo.window; const card = polaroidGeo.card; const winRect = rect(win.x, win.y, win.w, win.h); return ( {useShaderPass ? ( {cinemaOn ? ( {toneEffect && toneOn ? ( ) : ( )} ) : toneEffect && toneOn ? ( ) : null} ) : ( )} {grainOpacity > 0 && noiseEffect && ( )} {/* Thin seam between image window and card */} {renderGPSWatermark()} ); } // WALL FRAME (library): the photo is cover-cropped (center) into the frame's // fixed 2:3 window (rotated artwork) — the mat opening of a real frame hides // the print edges. The window never letterboxes a mismatched aspect; the // artwork PNG is drawn rotated over the crop afterwards (its transparent // window reveals the photo). if (mode === 'library' && wallGeo && wallImage && skiaImage && libraryImageUri) { const win = wallGeo.window; const drawMatrix = wallGeo.drawMatrix; const winRect = rect(win.x, win.y, win.w, win.h); return ( {useShaderPass ? ( {cinemaOn ? ( {toneEffect && toneOn ? ( ) : ( )} ) : toneEffect && toneOn ? ( ) : null} ) : ( )} {grainOpacity > 0 && noiseEffect && ( )} {/* Artwork (rotated) over the crop: transparent window reveals it, mat covers the rest. Drawn last so semi-transparent mat edges blend over the photo instead of the reverse. */} {renderGPSWatermark()} ); } return ( {libraryImageUri && skiaImage ? ( <> {useShaderPass ? ( // DR/Highlight/Shadow tone + Cinema seasonal grade, both live // over the photo. 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"). Cinema samples the tone output when both // are on (chain: cinema -> tone -> image), matrix applies after. {cinemaOn ? ( {toneEffect && toneOn ? ( ) : ( )} ) : toneEffect && toneOn ? ( ) : null} ) : dispRect ? ( ) : ( )} {grainOpacity > 0 && noiseEffect && ( {/* Grain covers the whole photo area — the composition region under a ratio band, the full screen otherwise. */} )} {/* 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 )} ); }