import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle, type ComponentProps } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions, AppState } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { activateKeepAwake, deactivateKeepAwake } from 'expo-keep-awake'; import { useCameraDevice, type CameraPhotoOutput } from 'react-native-vision-camera'; import { SkiaCamera, type SkiaCameraRef } from 'react-native-vision-camera-skia'; import UltraWide, { UltraWidePreview } from '../../modules/recipescam-ultrawide'; import { createSynchronizable } from 'react-native-worklets'; import { Canvas, ClipOp, useImage, useFont, Text as SkiaText, rect, Shader, Skia, Group, Rect, RadialGradient, ColorMatrix, Blur, Image as SkiaImage, ImageShader, BlendMode, TileMode, FilterMode, MipmapMode, FontStyle, } from '@shopify/react-native-skia'; import { Lock } from 'lucide-react-native'; import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments, ASPECT_RATIO_W_H, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT, MIN_CROP_FRAC } from '../types'; import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils'; import { TONE_SKSL, getToneUniforms, toneUniformArray, GLOW_SKSL, glowUniformArray, GLOW_UNIFORMS, CLARITY_SKSL, clarityUniforms } from '../utils/toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader'; import { polaroidLayout, wallframeLayout, WALLFRAME_PNG_W, WALLFRAME_PNG_H } from '../utils/frameUtils'; import { applyPhotoRotation } from '../utils/skiaImage'; import { detectTilt } from '../utils/horizon'; interface ViewfinderProps { mode: 'camera' | 'library'; // Camera lens facing for the live viewfinder; flipping restarts the session // on the other device. facing?: 'back' | 'front'; recipe: Recipe; selectedFrame: FrameId; useGeotag: boolean; gpsInfo: GPSInfo | null; libraryImageUri: string | null; // Library only: screen height still visible above the bottom deck. The whole // viewfinder geometry is letterboxed into it so no part of the photo (and no // custom-watermark position) hides under an open panel. Camera ignores this: // its feed rects are normalized against the real window. availableHeight?: number | null; // Library only: user rotation of the inserted photo — the quarter turn in // degrees clockwise (0/90/180/270) plus the fine STRAIGHTEN offset, applied // on top of that turn. photoRotation?: 0 | 90 | 180 | 270; photoStraighten?: number; // WATERMARK tab: double-tap ON the mark re-opens the text editor (App bumps // a nonce so the panel puts the caret back in its input). onWmEdit?: () => void; 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; // FRAME-tab CROP (library plain frames only — the camera composes through // aspectRatio above, polaroid/wall through their own framed window). A fixed // ratio previews the exact center-crop the export writes; 'free' shows the // whole photo with a draggable keep-rectangle. Zoom/pan is suspended while a // crop is active so the preview and the file can never disagree. cropRatio?: CropRatio; // The ratio the CROP strip's APPLY committed. Equal to cropRatio only while // the crop is confirmed: the viewer then shows the crop alone (no band // outline, no dim surround). Picking any other ratio drops back to framing. cropApplied?: CropRatio | null; cropRect?: CropRect; onCropRectChange?: (r: CropRect) => void; // 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; // Metering-mode tonal look (highlight-weighted): extra adjustments merged on // top of the recipe for BOTH the preview matrix/tone and the export. Real // highlight-weighted metering (GR / Sony) doesn't only underexpose — it meters // for the BRIGHTEST area to keep its detail (absolute no-blowout): highlight // roll, deep shadow crush, raised contrast/clarity. Carries the small EV dip // too (exposureCompensation), so preview and export share ONE EV source; the // UI sliders keep showing the raw recipe. meteringAdjustments?: Partial; // Continuous panel parameter whose slider row is open (App lifts it from the // AdjustmentPanel). While set, a vertical drag ANYWHERE on the live image // adjusts this parameter (up = increase) — no need to grab the small slider. // Null while no param row is open; then a locked-AE drag adjusts EV instead. imageAdjustTarget?: { key: string; min: number; max: number; step: number; value: number; onChange: (v: number) => void; } | null; // Hold-to-compare: while armed (a LIGHT/WB/FX panel is open) pressing and // holding anywhere on the picture asks App to draw it with the last edit // undone; the finger coming off asks for the edited look back, so the user // can flick between the before and the after. Absent prop = feature off. onComparePeek?: (on: boolean) => void; // Touch-to-shutter (gear sheet): a plain tap focuses as always, then fires a // capture once the focus promise settles. Absent prop = tap only focuses. onTouchShutter?: () => void; // Custom text watermark (WATERMARK tab -> CUSTOM chip). Drawn live on the // image like the GPS mark; dragging/tapping the image moves it. Fractions are // 0..1 of the photo/window area. Absent prop = feature off. customWm?: { enabled: boolean; text: string; x: number; y: number; }; onWmPositionChange?: (x: number, y: number) => void; // GPS mark placement (WATERMARK tab drag + pinch): the same 0..1 fractions // of the same area customWm uses, TOP-LEFT anchored; size multiplies the // base 3.2%-of-width size. Absent = the historical fixed top-left look. gpsWm?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean }; onGpsWmChange?: (x: number, y: number, size: number) => void; // Pinch on the custom mark: its SIZE, exactly like the panel's SIZE chip. onWmSizeChange?: (size: number) => void; // WATERMARK tab ROTATE chip: clockwise degrees (0/90/180/270). Rotates the // custom text AND the GPS lines around their own anchors — same angle the // export burns in. wmRotation?: number; // True while the WATERMARK panel is open — that panel IS the mark's edit // session. The mark draws whenever the CUSTOM chip is ON with text, but it // only CAPTURES image touches (placement drags in camera mode, click-through // to focus / zoom) while this is set, so a watermark left ON can never // swallow the camera's tap-to-focus or the library's pinch/double-tap. wmEditing?: boolean; // True while the FRAME tab is the open deck tab. Pinching then magnifies the // WHOLE preview — frame, photo and watermarks together — instead of the // photo: a preview-only loupe for placing the mark. The saved file never // changes, because the export reads the photo zoom, not this view zoom. frameTabActive?: boolean; // WALL FRAME hung landscape (4000x3117) instead of portrait. wallLandscape?: boolean; } export interface ViewfinderHandle { /** * Current interactive photo reposition inside a framed library window * (RETRO INSTANT / WALL FRAME): scale s (>=1) and the visible window-center * as 0..1 fractions of the photo's cover plane (the area that fills the * window at 1x). Null when no framed window is showing or the photo is * unzoomed. App passes it to the export engine so the saved file shows * exactly the crop the user framed after pinch/drag. */ getFrameWindowZoom(): { s: number; u: number; v: number } | null; /** * True while the 0.5x chip holds the native ultra-wide lens. The CameraX * session is off then, so App must capture through the native module instead * of `photoOutput`. */ isUltraWide(): boolean; /** * FRAME-tab CROP with a fixed ratio: the part of the photo the on-screen * keep-band shows, as 0..1 fractions of the (post-rotation) source image, so * the saved file matches the framing after pinch/drag. Null when no * fixed-ratio crop is active. */ getCropRatioRect(): CropRect | null; /** * FRAME-tab AUTO straighten: tilt, in degrees, of the dominant near- * horizontal line in the library photo — the horizon it was shot with. * Null when the picture holds no line worth trusting, so App can leave the * fine angle alone. Read from the quarter-turned source WITHOUT the current * straighten, so pressing AUTO twice answers the same both times. */ detectHorizon(): number | null; } 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('TONE_SKSL compile failed: ' + e); return null; } })(); const cinemaEffect = (() => { try { return Skia.RuntimeEffect.Make(CINEMA_SKSL); } catch (e) { console.error('CINEMA_SKSL compile failed: ' + e); return null; } })(); // Bright Pass Filter effect for the HDF glow (GLOW_SKSL) — hoisted with the // two above so the preview worklet can capture it instead of compiling per // frame. const glowEffect = (() => { try { return Skia.RuntimeEffect.Make(GLOW_SKSL); } catch (e) { console.error('GLOW_SKSL compile failed: ' + e); return null; } })(); // Unsharp twin of the camera worklet's MakeMatrixConvolution (see CLARITY_SKSL) // for the library preview, which draws through the declarative tree instead. const clarityEffect = (() => { try { return Skia.RuntimeEffect.Make(CLARITY_SKSL); } catch (e) { console.error('CLARITY_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; const Viewfinder = forwardRef(function Viewfinder( { mode, facing = 'back', recipe, selectedFrame, useGeotag, gpsInfo, libraryImageUri, availableHeight, cameraPermissionGranted, onRequestCameraPermission, photoOutput, aspectRatio, cropRatio = 'none', cropApplied = null, cropRect = DEFAULT_CROP_RECT, onCropRectChange, rawEnabled, rawOutput, onSessionError, onExposureChange, meteringAdjustments, imageAdjustTarget, onComparePeek, onTouchShutter, customWm = { enabled: false, text: '', x: 0.5, y: 0.5 }, onWmPositionChange, gpsWm, onGpsWmChange, onWmSizeChange, onWmEdit, wmEditing = false, frameTabActive = false, photoRotation = 0, photoStraighten = 0, wmRotation = 0, wallLandscape = false, }: ViewfinderProps, ref ) { // Fullscreen hero: the viewfinder fills the entire screen, every overlay // rect derives from window size. const { width: vw, height: winH } = useWindowDimensions(); // Library: everything below this line is the bottom deck, so the photo is // letterboxed above it (top-anchored: same fractions, same export). const vh = mode === 'library' && availableHeight && availableHeight > 0 ? availableHeight : winH; 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 = mode === 'camera' && 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 }; // The band is a same-zoom crop WINDOW over the full sensor view, not the // photo re-scaled to the screen width: anchor on the native sensor box // (upright 3:4, contain-fitted to the screen) and cut the ratio strip out // of it at the same scale. 4:3 (== native) therefore fills the full sensor // width at the reference zoom, and 3:2 is the identical vertical view with // a narrower horizontal crop — switching ratio never zooms the scene, so a // 3:2 band never shows a taller image than 4:3. // ponytail: sensor assumed upright 3:4 (quality-mode captures); if a device // negotiates another photo aspect, derive it from the session instead. const nativeWH = 3 / 4; const nativeH = Math.min(vh, vw / nativeWH); const nativeW = nativeH * nativeWH; const h = ratioWH <= nativeWH ? nativeH : nativeW / ratioWH; const w = ratioWH * h; return { x: (vw - w) / 2, y: (vh - h) / 2, w, h }; }, [regionBand, ratioWH, vw, vh]); // FRAME-tab CROP geometry. Only the plain full-picture frames crop the photo // directly (same set the camera's ratio band serves); polaroid/wall already // crop through their own window and keep their framed ratio crop. const libCropPlainFrame = selectedFrame === 'none' || selectedFrame === 'classic-white' || selectedFrame === 'cinematic'; const libCropActive = mode === 'library' && cropRatio !== 'none' && libCropPlainFrame; const libFreeCrop = libCropActive && cropRatio === 'free'; // CROP's second step: once APPLY has committed the ratio the band is no // longer an outline over the whole picture — the crop itself is the view. const libCropApplied = libCropActive && cropApplied === cropRatio; // 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(facing); // CameraX negotiates the CameraFrameOutput aspect on its own (default 16:9 on // this device), which center-crops the 4:3 sensor — so the ratio band could // never display 100% of the sensor pixels. Force the frame stream to a 4:3 // (sensor aspect) resolution supported for 'stream' outputs; the photo output // keeps its own full-res 4:3 size and FOV, so the viewfinder then matches the // captured photo exactly. Best supported 4:3 size closest to 1280x960 // (720p-class, keeps the frame pipeline light); CameraX may still round it. const frameTarget = useMemo(() => { if (!device) return undefined; try { let best: { width: number; height: number } | undefined; let bestScore = Infinity; for (const s of device.getSupportedResolutions('stream')) { const w = s.width; const h = s.height; if (!(w > 0 && h > 0)) continue; if (Math.abs(w / h - 4 / 3) > 0.03) continue; const score = Math.abs(Math.sqrt(w * h) - Math.sqrt(1280 * 960)); if (score < bestScore) { bestScore = score; best = { width: w, height: h }; } } return best ?? undefined; } catch { return undefined; } }, [device]); const loadedImage = useImage(libraryImageUri || ''); // FRAME-tab rotation: pre-rotate the source, so the width()/height() swap // flows through every derived rect (polaroid window aspect, cover crop, // watermark area) and the export matches the preview for free. const skiaImage = useMemo( () => applyPhotoRotation(loadedImage, photoRotation, photoStraighten), [loadedImage, photoRotation, photoStraighten] ); // Mirror of the two inputs of the above, for detectHorizon: the imperative // handle is captured once (deps [ref]) and must read the LIVE source. const horizonSrcRef = useRef({ image: loadedImage, quarter: photoRotation }); horizonSrcRef.current = { image: loadedImage, quarter: photoRotation }; // 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) ); // GPS mark SIZE (pinch while placing it): the same two faces, re-sized, so // the preview grows with the multiplier the export burns in. const gpsIconSize = Math.round(vw * 0.032 * (gpsWm?.size ?? 1)); const gpsFont = useMemo( () => (customFont ? Skia.Font(customFont.getTypeface(), gpsIconSize) : null), [customFont, gpsIconSize] ); const gpsIconFont = useMemo( () => (iconFont ? Skia.Font(iconFont.getTypeface(), gpsIconSize) : null), [iconFont, gpsIconSize] ); const adjustments = meteringAdjustments ? { ...recipe.adjustments, ...meteringAdjustments } : recipe.adjustments; // Exposure compensation, applied as a software 2^EV matrix gain for BOTH // modes — the camera's hardware AE bias is never touched. setExposureBias // re-locks AE on the Xiaomi and flashes the preview, and the bias lands // asynchronously, so a shot exposed through it could disagree with what the // viewfinder showed at shutter time. The software gain is instant and // flicker-free, and it is what the export applies too (no evFromCamera), so // preview and file always match. EV_RANGE ±3 caps the gain. const clampEvApplied = (v: number) => Math.max(-3, Math.min(3, v)); const evApplied = clampEvApplied(adjustments.exposureCompensation ?? 0); const colorMatrix = applyExposureGain( getSkiaColorMatrix(recipe.baseFilter, adjustments), evApplied ); // 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, vib, shadow split RGB, highlight split RGB] for // the preview worklet; all-zero = no tone pass. A stock may keep the pass on // with every user knob at 0 (Classic Neg split tone, Classic Chrome crush). const toneParams = toneUniformArray(getToneUniforms(adjustments, recipe.baseFilter)); const toneSync = useMemo( () => createSynchronizable(new Array(10).fill(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 [] ); // Highlight Diffusion Filter strength (0..10); 0 = no glow pass. const hdfSync = useMemo( () => createSynchronizable(adjustments.hdf ?? 0), // eslint-disable-next-line react-hooks/exhaustive-deps [] ); // Clarity (-10..10): local unsharp mask / mist, the same numbers exportEngine // uses, so the live preview and the exported file agree. 0 = no filter. const claritySync = useMemo( () => createSynchronizable(adjustments.clarity ?? 0), // eslint-disable-next-line react-hooks/exhaustive-deps [] ); // Sharpening (-10..10): below 0 the knob softens the frame, the same blur the // export applies. Own sync so it wraps the clarity filter instead of the two // knobs clobbering each other. const softenSync = useMemo( () => createSynchronizable(adjustments.sharpening ?? 0), // eslint-disable-next-line react-hooks/exhaustive-deps [] ); useEffect(() => { colorMatrixSync.setBlocking(colorMatrix); toneSync.setBlocking(toneParams); cinemaSync.setBlocking(cinemaParams ? cinemaParams.flat : cinemaOffFlat()); hdfSync.setBlocking(adjustments.hdf ?? 0); claritySync.setBlocking(adjustments.clarity ?? 0); softenSync.setBlocking(adjustments.sharpening ?? 0); }, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, adjustments.clarity, adjustments.sharpening, colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync, softenSync]); // Camera controller lives behind SkiaCamera. The camera AE bias is never // written: EV (user + metering offset) is purely software matrix gain, for // the preview and for the export alike. const skiaCameraRef = useRef(null); const [cameraActive, setCameraActive] = useState(false); // Re-render kicker bumped on every onStarted. A flip remounts // (key change) while cameraActive never drops (unmount fires no onStopped), // so the reopen would not re-render and controller-derived UI (zoom chips) // stayed stuck hidden until the next touch — the new controller only becomes // readable from the ref once the fresh session has started. const [, bumpSessionStart] = useState(0); // Stable identities so React.memo(SkiaCameraImpl) does not re-render (and // re-run its frame-output setup) on every adjustments change during slider // drags — inline lambdas here used to break memo and stall the main thread. const handleCameraStarted = useCallback(() => { startedOnceRef.current = true; watchdogBudgetRef.current = 0; setCameraActive(true); bumpSessionStart((n) => n + 1); }, []); const handleCameraStopped = useCallback(() => { setCameraActive(false); }, []); // Camera session pauses when the app backgrounds; toggling isActive back on // resume restarts it. Without this the preview stayed black after unlock. const [appActive, setAppActive] = useState(AppState.currentState === 'active'); const startedOnceRef = useRef(false); const cameraActiveRef = useRef(false); cameraActiveRef.current = cameraActive; const appActiveRef = useRef(appActive); appActiveRef.current = appActive; // Watchdog bookkeeping — see the camera-watchdog effect near handleFrame. const bouncePendingRef = useRef(false); const watchdogBudgetRef = useRef(0); // Full SkiaCamera remount counter. Bumping unmounts and mounts a // fresh one (its unmount clears the lib's offscreen surface cache), the only // reliable revive on this device — an isActive toggle or a plain session // reconfigure (flip) does NOT clear the wedged Skia/EGL state. const [cameraMountNonce, setCameraMountNonce] = useState(0); useEffect(() => { const sub = AppState.addEventListener('change', (s) => setAppActive(s === 'active')); return () => sub.remove(); }, []); // Keep the screen lit while the live camera is showing. Without this the // display sleeps after the system timeout; when the user wakes it much later // (deep idle/doze) the preview surface is gone but the JS AppState never saw // a background event, so the session is never restarted and the viewfinder // (and capture) stay black until the camera is re-mounted (flip/switch). useEffect(() => { if (mode === 'camera') { activateKeepAwake('camera-viewfinder'); return () => deactivateKeepAwake('camera-viewfinder'); } }, [mode]); // 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; // Native zoom. CameraX on this class of device exposes one back logical // camera (1x main physical + digital crop to 10x); the ultra-wide/tele // logical cameras are not in CameraX's device list, so preset chips snap the // controller zoom ratio (1x optical, 2x-10x digital). Applied ratio survives // session restarts (CameraX resets to 1x on each fresh session). const [zoomRatio, setZoomRatio] = useState(1); const zoomRef = useRef(1); useEffect(() => { zoomRef.current = zoomRatio; }, [zoomRatio]); // The back ultra-wide lens is a hidden Camera2 id (see the native module), so // 0.5x is not a CameraX zoom ratio: the chip swaps the live preview over to a // native Camera2 session on that lens. Only the back side has one. const [ultraWideId, setUltraWideId] = useState(''); const [ultraWideOn, setUltraWideOn] = useState(false); const ultraWideOnRef = useRef(false); ultraWideOnRef.current = ultraWideOn; useEffect(() => { let alive = true; UltraWide.availableAsync() .then((id) => { if (alive) setUltraWideId(id || ''); }) .catch((e) => console.warn('Ultra-wide probe failed', String(e).slice(0, 120))); return () => { alive = false; }; }, []); const ultraWideReady = ultraWideId !== '' && facing === 'back'; const ultraWideReadyRef = useRef(false); ultraWideReadyRef.current = ultraWideReady; // Handoff cover. The two back lenses cannot be open at once, so leaving 0.5x // closes the native session first and the 1x preview only then reopens: for // ~1s the viewfinder shows a frozen stale frame and then the reopening // session's out-of-focus, shifted first frames. The ultra-wide keeps drawing // its own last frame over that instead (the native `cover` prop), so the swap // reads as one clean cut from 0.5x to 1x. const [uwCover, setUwCover] = useState(false); const uwCoverTimer = useRef | null>(null); const uwFramePoll = useRef | null>(null); const stopCoverTimer = useCallback(() => { if (uwCoverTimer.current) clearTimeout(uwCoverTimer.current); uwCoverTimer.current = null; if (uwFramePoll.current) clearInterval(uwFramePoll.current); uwFramePoll.current = null; }, []); const holdCover = useCallback( (ms?: number) => { stopCoverTimer(); if (ms == null) { setUwCover(true); return; } uwCoverTimer.current = setTimeout(() => { uwCoverTimer.current = null; setUwCover(false); }, ms); }, [stopCoverTimer] ); useEffect(() => stopCoverTimer, [stopCoverTimer]); // Bumped on every lens handoff: a slow release belonging to an older one must // not touch the state a newer zoom choice already owns. const uwHandoff = useRef(0); const applyZoom = useCallback( (ratio: number) => { const native = ratio === 0.5 && ultraWideReadyRef.current; // The reticle points at a region of a lens that is about to go away. if (native !== ultraWideOnRef.current) setFocusPoint(null); setZoomRatio(ratio); if (!native && ultraWideOnRef.current) { // Hold the outgoing 0.5x frame until the main preview really paints // again, instead of a fixed 700ms: measured on this device the fresh // 1x session lands anywhere from 0.2s to 0.9s after the tap, so the // old ceiling left a black hole when it ran slow and a stale frame // when it ran fast. The frame beat advances on every delivered frame // of the main camera, so it is the honest "1x is live" signal. holdCover(1500); // ceiling: never keep the stale frame up for good const beatAtSwap = frameBeatSync.getDirty(); uwFramePoll.current = setInterval(() => { if (frameBeatSync.getDirty() - beatAtSwap < 3) return; stopCoverTimer(); setUwCover(false); }, 50); // The main session is only switched back on once the native lens has // actually let go of the back camera slot. Activating it earlier makes // CameraX open the 1x while the HAL still holds that lens, and the // refused open is retried on a fixed ~520ms timeout - the bulk of the // handoff time. const handoff = ++uwHandoff.current; UltraWide.closeAsync() .catch(() => {}) .then(() => { if (uwHandoff.current !== handoff) return; setUltraWideOn(false); }); } else { uwHandoff.current++; // a release still in flight belongs to the past now setUltraWideOn(native); if (native) { holdCover(); // Back to 0.5x while the previous handoff was still releasing the // lens: it is closed by now, so ask for it back. if (ultraWideOnRef.current) UltraWide.reopenAsync().catch(() => {}); } } // The native lens is a different camera: the main session is off while // it is live, so never carry the 0.5 over as a zoom ratio onto it. zoomRef.current = native ? 1 : ratio; const ctl = skiaCameraRef.current?.controller; if (ctl && !native) { ctl.setZoom(ratio).catch((e) => console.warn('setZoom failed', String(e).slice(0, 120))); } }, [holdCover, stopCoverTimer] ); // 0.5x is a chip wherever an ultra-wide can actually be shown: the hidden // Camera2 lens above, or a bound CameraX device whose minZoomRatio reaches // below 1 (a logical multi-camera with the ultra-wide wired in). A plain // single-lens device reports 1 (and 0.0 before the session binds, which is // why the gate is `> 0`), and setZoom rejects anything below min. const zoomPresets = useMemo(() => { const base = [1, 2, 3, 5, 10]; const min = device?.minZoom ?? 1; if (ultraWideReady) return [0.5, ...base]; return min > 0 && min <= 0.5 ? [0.5, ...base] : base; }, [device, ultraWideReady]); // Re-apply after the camera session restarts (background resume, camera↔ // library switch): a fresh session starts back at 1x. useEffect(() => { if (!controller) return; if (zoomRef.current !== 1) { controller.setZoom(zoomRef.current).catch(() => {}); } }, [controller]); // Switching back/front restarts the session at the new lens: never carry a // digital zoom across the switch. useEffect(() => { zoomRef.current = 1; setZoomRatio(1); setUltraWideOn(false); stopCoverTimer(); setUwCover(false); }, [facing, stopCoverTimer]); // The quick-EV bubble/drag is the recipe's OWN EV: `adjustments` above also // carries the metering-mode dip (highlight-weighted), so reading it here // showed EV -0.5 while the LIGHT tab slider sat at 0 — and a drag then wrote // that leaked value back as the user's EV. const evStops = recipe.adjustments.exposureCompensation ?? 0; useImperativeHandle( ref, () => ({ // Frame-window (polaroid/wall) zoom, for export parity. Read // live through the ref — the handle is captured once with deps [ref]. getFrameWindowZoom() { return frameZoomRef.current; }, isUltraWide() { return ultraWideOnRef.current; }, getCropRatioRect() { return cropRatioRectRef.current; }, // AUTO straighten: the angle the picture asks for. Computed on demand // (one <=256px thumbnail, tens of ms) — never on a slider drag. detectHorizon() { const src = horizonSrcRef.current; return detectTilt(applyPhotoRotation(src.image, src.quarter, 0)); }, }), [ref] ); // 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) => { // 0.5x is a native lens whose CameraX session is stopped, so focusTo() // has no camera behind it and the tap has to go to the module. if (ultraWideOnRef.current) { setFocusPoint({ x, y }); try { await UltraWide.focusAsync(x, y); } catch (e) { console.warn('Ultra-wide focus failed: ' + String(e).slice(0, 120)); } return; } if (!cameraActive) return; const cam = skiaCameraRef.current; const d = cam?.controller?.device; if (!d) return; if (!d.supportsFocusMetering && !d.supportsExposureMetering && !d.supportsWhiteBalanceMetering) { console.warn('Focus/exposure metering unsupported on this device'); return; } setFocusPoint({ x, y }); try { const t0 = Date.now(); await cam.focusTo( { x, y }, { responsiveness: 'snappy', adaptiveness: 'locked', autoResetAfter: null } ); console.log(`[focus] AE/AF locked at ${Math.round(x)},${Math.round(y)} in ${Date.now() - t0}ms`); } catch (e) { console.warn('Focus lock failed: ' + String(e).slice(0, 120)); } }; const handleUnlockFocus = async () => { if (ultraWideOnRef.current) { setFocusPoint(null); try { await UltraWide.resetFocusAsync(); } catch (e) { console.warn('Ultra-wide focus reset failed: ' + String(e).slice(0, 120)); } return; } const cam = skiaCameraRef.current; if (!cam) return; try { await cam.resetFocus(); setFocusPoint(null); } catch (e) { console.warn('Focus reset failed: ' + String(e).slice(0, 120)); } }; // Quick exposure slider — appears while AE/AF is locked. Drag up/down maps // directly to the recipe EV in stops (same live software-matrix path as the // EV slider, and the same EV the export applies). 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); // Last EV value actually pushed to App. Quick-EV drags otherwise fire a full // adjustments re-render on EVERY pointer move (30-60Hz), even while the // finger holds a step without the value changing — that render storm stalls // the main thread and beats the Skia preview. Only push real nấc changes. const evLastSentRef = useRef(null); const evThumbY = evTrackTop + ((EV_RANGE - clampEv(evStops)) / (2 * EV_RANGE)) * evTrackH; // Screen-absolute Y of the gesture. locationY is relative to whichever view // the finger currently hit-tests over, so crossing the small track's edge // mid-drag changed the origin by the track offset and snapped EV by ~3 stops // (the "jumps to the other end" bug). pageY never moves. const pageYOf = (e: any): number => { const t = e?.nativeEvent?.touches?.[0]; if (t && typeof t.pageY === 'number') return t.pageY; const p = e?.nativeEvent?.pageY; if (typeof p === 'number') return p; return e?.nativeEvent?.locationY ?? 0; }; const onEvSliderGrant = (e: any) => { const y0 = pageYOf(e); evDragRef.current = { y0, ev0: evStops }; evLastSentRef.current = evStops; }; const onEvSliderMove = (e: any) => { const g = evDragRef.current; if (!g || !onExposureChange) return; const dy = g.y0 - pageYOf(e); // drag up = brighter const ev = clampEv(g.ev0 + dy / evPxPerStop); const rounded = Math.round(ev * 10) / 10; if (rounded === evLastSentRef.current) return; // same step: skip re-render evLastSentRef.current = rounded; onExposureChange(rounded); }; const onEvSliderRelease = () => { evDragRef.current = null; }; // ---- Drag-anywhere-on-the-image adjustment -------------------------------- // Priority: an open panel param row (imageAdjustTarget) > EV (AE locked). // A plain tap (no movement) always falls through to tap-to-focus. const TAP_SLOP = 14; // px of movement before a touch counts as a drag // ---- Hold-to-compare (see onComparePeek) --------------------------------- // The timer only arms when App has something to compare against; moving past // TAP_SLOP (or a second finger) hands the touch back to its normal job — // adjust / pinch / pan — and drops the peek. const HOLD_MS = 350; const holdTimerRef = useRef | null>(null); const holdAnchorRef = useRef<{ x: number; y: number } | null>(null); const peekingRef = useRef(false); const onComparePeekRef = useRef(onComparePeek); onComparePeekRef.current = onComparePeek; const endPeek = () => { if (!peekingRef.current) return; peekingRef.current = false; onComparePeekRef.current?.(false); }; const cancelHold = () => { if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; } }; const startHold = (x: number, y: number) => { cancelHold(); holdAnchorRef.current = { x, y }; if (!onComparePeekRef.current) return; holdTimerRef.current = setTimeout(() => { holdTimerRef.current = null; peekingRef.current = true; onComparePeekRef.current?.(true); }, HOLD_MS); }; // Ends any hold and reports whether this gesture WAS peeking — a peek is not // a tap, so its release must skip the tap actions below. const endHold = () => { cancelHold(); holdAnchorRef.current = null; const was = peekingRef.current; endPeek(); return was; }; const holdNoteMove = (x: number, y: number) => { const a = holdAnchorRef.current; if (!a) return; if (Math.abs(x - a.x) <= TAP_SLOP && Math.abs(y - a.y) <= TAP_SLOP) return; holdAnchorRef.current = null; cancelHold(); endPeek(); }; const imageAdjustTargetRef = useRef(imageAdjustTarget); imageAdjustTargetRef.current = imageAdjustTarget; type ImgDragSession = { y0: number; kind: 'param' | 'ev' | 'wm' | 'focus'; v0: number; param: { min: number; max: number; step: number; onChange: (v: number) => void }; moved: boolean; lastSent: number | null; }; const imgDragRef = useRef(null); const onImageDragGrant = (e: any) => { const t = imageAdjustTargetRef.current; let kind: ImgDragSession['kind'] = 'focus'; let v0 = 0; let param: ImgDragSession['param'] = { min: 0, max: 0, step: 0, onChange: () => {} }; if (t) { kind = 'param'; v0 = t.value; param = { min: t.min, max: t.max, step: t.step, onChange: t.onChange }; } else if (wmArmed || gpsWmArmed) { // Watermark armed: every touch drags the mark (an open param slider keeps // priority; the AE-lock EV drag yields while armed). Anchor the drag — // the mark only MOVES once the finger does, it never jumps under it. kind = 'wm'; const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent; const tp = { x: t?.locationX ?? 0, y: t?.locationY ?? 0 }; const tgt = wmTargetAt(tp.x, tp.y) ?? (wmArmed ? 'custom' : 'gps'); wmDragTgtRef.current = tgt; beginWmDrag(tgt, tp.x, tp.y); } else if (focusPoint && onExposureChange) { kind = 'ev'; v0 = evStops; } // Framed-window pinch/pan anchors (camera): seeded at touch-down like the // library's onLibTouchStart, consumed in onImageDragMove below. const gts = e?.nativeEvent?.touches ?? []; panRef.current = gts.length === 1 ? { x: gts[0].locationX, y: gts[0].locationY, px: gts[0].pageX, py: gts[0].pageY, tx: viewZoomed ? viewZoom.tx : libZoom.tx, ty: viewZoomed ? viewZoom.ty : libZoom.ty, } : null; panUsedRef.current = false; if (gts.length < 2) pinchRef.current = null; imgDragRef.current = { y0: pageYOf(e), kind, v0, param, moved: false, lastSent: null }; const ht = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent; startHold(ht?.pageX ?? 0, pageYOf(e)); }; const onImageDragMove = (e: any) => { const g = imgDragRef.current; const holdTs = e?.nativeEvent?.touches ?? []; if (holdTs.length >= 2) endHold(); else holdNoteMove(holdTs[0]?.pageX ?? 0, pageYOf(e)); // Framed window (polaroid card / wall opening) on the live camera: two // fingers pinch the picture and drag it inside the frame — exactly the crop // the export takes (frameWindowZoom). One finger drags it too, but only a // touch that no parameter row and no AE-lock EV drag has claimed. const wts = e?.nativeEvent?.touches; // Two fingers: a mark under them resizes (the WATERMARK panel's own pinch); // anything else on the FRAME tab magnifies the whole view. if (wts && wts.length >= 2) { const mx = (wts[0].locationX + wts[1].locationX) / 2; const my = (wts[0].locationY + wts[1].locationY) / 2; const d = Math.hypot( wts[0].locationX - wts[1].locationX, wts[0].locationY - wts[1].locationY ); const mi = { x: mx, y: my }; const mark = pinchMarkAt(mi.x, mi.y) ?? wmPinchRef.current?.tgt ?? null; if (mark) { const p = wmPinchRef.current; if (!p) wmPinchRef.current = { tgt: mark, d, size: wmSizeOf(mark) }; else { applyWmScale(p.tgt, (p.size * d) / Math.max(p.d, 1)); if (g) g.moved = true; } return; } if (frameTabActive) { applyViewPinch(wts); if (g) g.moved = true; return; } } // FRAME tab, magnified view: one finger drags the picture inside the // viewport (preview only, like the pinch). An armed mark keeps the touch. if (viewZoomed && wts?.length === 1 && g?.kind === 'focus' && !pinchRef.current) { const pan0 = panRef.current; if (pan0) { // pageX/pageY: absolute screen dp, immune to the view transform, so the // picture tracks the finger 1:1 (locationX is scaled by the matrix). const dx = wts[0].pageX - pan0.px; const dy = wts[0].pageY - pan0.py; if (Math.abs(dx) > TAP_SLOP || Math.abs(dy) > TAP_SLOP) panUsedRef.current = true; if (panUsedRef.current) { setViewZoom(clampViewZoom({ s: viewZoom.s, tx: pan0.tx + dx, ty: pan0.ty + dy })); g.moved = true; } } return; } if (g && g.kind !== 'wm' && !wmArmed && zoomWindowRect()) { if (wts && wts.length >= 2) { const [a, b] = wts; const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY); let p = pinchRef.current; if (!p) { p = pinchRef.current = { d, s: libZoom.s, tx: libZoom.tx, ty: libZoom.ty, fx: (a.locationX + b.locationX) / 2, fy: (a.locationY + b.locationY) / 2, }; } const ns = clampZoom((p.s * d) / Math.max(p.d, 1)); const k = ns / p.s; setLibZoom(clampZoomToWindow(ns, p.fx - k * (p.fx - p.tx), p.fy - k * (p.fy - p.ty))); g.moved = true; return; } if (wts && wts.length === 1 && g.kind === 'focus' && !pinchRef.current) { const t = wts[0]; const pan0 = panRef.current; if (pan0) { const dx = t.locationX - pan0.x; const dyP = t.locationY - pan0.y; if (Math.abs(dx) > TAP_SLOP || Math.abs(dyP) > TAP_SLOP) panUsedRef.current = true; if (panUsedRef.current) { setLibZoom(clampZoomToWindow(libZoom.s, pan0.tx + dx, pan0.ty + dyP)); g.moved = true; } } return; } } if (!g || g.kind === 'focus') return; if (g.kind === 'wm') { // Watermark drag: the mark keeps its place under the finger and travels // by the finger's DELTA — it must never jump to the touch point. The // second finger (pinch, above) resizes it instead; the commit is below. const ts = e?.nativeEvent?.touches ?? []; const t = ts[0] ?? e?.nativeEvent; const { x, y } = { x: t?.locationX ?? 0, y: t?.locationY ?? 0 }; const d0 = wmDragRef.current; if (d0 && (Math.abs(x - d0.px) > TAP_SLOP || Math.abs(y - d0.py) > TAP_SLOP)) { d0.moved = true; } if (d0?.moved) { moveWmDrag(x, y); g.moved = true; } return; } const dy = g.y0 - pageYOf(e); // up = positive / increase if (!g.moved) { if (Math.abs(dy) < TAP_SLOP) return; g.moved = true; } if (g.kind === 'ev') { if (!onExposureChange) return; const ev = clampEv(g.v0 + dy / evPxPerStop); const rounded = Math.round(ev * 10) / 10; if (rounded === g.lastSent) return; g.lastSent = rounded; evLastSentRef.current = rounded; onExposureChange(rounded); return; } const range = g.param.max - g.param.min; let v = g.v0 + (dy * range) / evTrackH; // same feel as the EV track v = Math.max(g.param.min, Math.min(g.param.max, v)); if (g.param.step > 0) v = Math.round(v / g.param.step) * g.param.step; const rounded = Math.round(v * 1000) / 1000; if (rounded === g.lastSent) return; g.lastSent = rounded; g.param.onChange(rounded); }; // Latest prop through a ref: the responder props read it on every event, but // a ref keeps a stale closure from ever firing a capture after the toggle // was switched off mid-gesture. const onTouchShutterRef = useRef(onTouchShutter); onTouchShutterRef.current = onTouchShutter; const onImageDragEnd = (e: any) => { const g = imgDragRef.current; imgDragRef.current = null; const peeked = endHold(); wmPinchRef.current = null; wmDragTgtRef.current = null; wmDragRef.current = null; viewPinchRef.current = null; pinchRef.current = null; panRef.current = null; panUsedRef.current = false; if (!g) return; if (g.kind === 'wm') { // A tap (never a drag) drops the text at the tapped point; a drag has // already moved it. Never lock AE/AF while the watermark is armed. if (!g.moved) { const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent; placeWm(t?.locationX ?? 0, t?.locationY ?? 0); } commitWm(); return; } if (!g.moved && !peeked) { // Plain tap: lock focus at the tapped point (drag never re-locks). A // hold-to-compare is not a tap either. // Touch-to-shutter chains the capture AFTER the focus promise: focusTo() // only resolves once focus has fully settled (rejects on timeout/cancel), // so waiting on it is exactly "focus first, then shoot" — no timer. void handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY).then(() => onTouchShutterRef.current?.() ); } }; // 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), []); // Frame heartbeat for the camera watchdog: bumped in handleFrame on every // delivered frame. When the whole pipeline dies the session still reports // OPEN but frame delivery stops, so the heartbeat freezes. const frameBeatSync = useMemo(() => createSynchronizable(0), []); // Composition band fed to the frame worklet: [active, viewAspect, fx, fy, // fw, fh, z, u, v] where (fx,fy,fw,fh) is the photo-area rect (ratio band / // polaroid window / wall window) normalized to the view, and (z,u,v) is the // framed-window pinch/drag (cover-plane magnification + the plane fraction at // the window center, [6]<=1 meaning none). 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, 0, 0.5, 0.5]), [] ); // RETRO INSTANT 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'; frameBeatSync.setBlocking(frameBeatSync.getDirty() + 1); render(({ canvas, frameTexture }) => { // Quick-EV drags write colorMatrixSync from JS on every tick; a torn // read can throw inside MakeMatrix (or yield NaN -> black surface) for // one frame. Validate + try/catch, fall back to identity (no filter): // a 1-frame gain error beats a 1-frame black beat. DEBUG-EV. const paint = Skia.Paint(); let baseColorFilter: ReturnType | null = null; try { const cm = colorMatrixSync.getDirty(); if (cm != null && cm.length === 20 && cm.every((v) => typeof v === 'number' && Number.isFinite(v))) { baseColorFilter = Skia.ColorFilter.MakeMatrix(cm); paint.setColorFilter(baseColorFilter); } else { console.warn('Invalid color matrix (len ' + (cm == null ? 'null' : cm.length) + ') — filter skipped'); } } catch (e) { console.warn('Color matrix build failed: ' + e); } // CLARITY: the export pass 4 twin — unsharp convolution above 0, mist // blur below. SHARPENING < 0 rides the same slot: it softens the frame // the exported file gets too. Both sit on the base paint, under the // glow, and compose (soften wraps whatever clarity built). try { const clarityV = claritySync.getDirty(); const softenV = softenSync.getDirty(); const cv = typeof clarityV === 'number' && Number.isFinite(clarityV) ? clarityV : 0; const sv = typeof softenV === 'number' && Number.isFinite(softenV) ? softenV : 0; if (cv !== 0 || sv < 0) { const a = (cv / 10) * 0.8; let lensFilter = cv > 0 ? Skia.ImageFilter.MakeMatrixConvolution( 3, 3, [0, -a, 0, -a, 1 + 4 * a, -a, 0, -a, 0], 1, 0, 0, 0, T_CLAMP, true, null ) : cv < 0 ? Skia.ImageFilter.MakeBlur( Math.abs(cv / 10) * 4, Math.abs(cv / 10) * 4, T_CLAMP, null ) : null; if (sv < 0) { lensFilter = Skia.ImageFilter.MakeBlur( Math.abs(sv / 10) * 3, Math.abs(sv / 10) * 3, T_CLAMP, lensFilter ); } if (lensFilter != null) paint.setImageFilter(lensFilter); } } catch (e) { console.warn('Lens filter build failed: ' + e); } const tone = toneSync.getDirty(); const cinema = cinemaSync.getDirty(); const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0 || tone[3] !== 0 || tone[4] !== 0 || tone[5] !== 0 || tone[6] !== 0 || tone[7] !== 0 || tone[8] !== 0 || tone[9] !== 0 || tone[10] !== 0 || tone[11] !== 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 = () => { let fullShader = null; 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) fullShader = final; } catch (e) { if (!toneFailLogged.getBlocking()) { toneFailLogged.setBlocking(true); console.error('Tone shader failed, matrix-only: ' + e); } } } if (fullShader != null) { const w = frameTexture.width(); const h = frameTexture.height(); paint.setShader(fullShader); canvas.drawRect(Skia.XYWHRect(0, 0, w, h), paint); } else { canvas.drawImage(frameTexture, 0, 0, paint); } // HDF: mirror of export pass 5b — Bright Pass Filter on the LUMINANCE, // blurred, Screened back on top. Below the knee the pass returns exact // 0.0 and Screen against black is a no-op, so the shadows stay put // while only the lit areas spread their light. Same source (shader or // image) the base draw used, so the glow sits on processed pixels. // Radius is a fraction of width so preview/export bloom alike. const hdfV = hdfSync.getDirty(); if (hdfV > 0) { const strength = hdfV / 10; const width = frameTexture.width(); const sigma = width * (0.004 + 0.015 * strength); const srcShader = fullShader != null ? fullShader : frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE); const glowShader = glowEffect != null ? glowEffect.makeShaderWithChildren(glowUniformArray(), [srcShader]) : null; const glowPaint = Skia.Paint(); glowPaint.setBlendMode(BlendMode.Screen); glowPaint.setAlphaf(0.15 + 0.35 * strength); if (glowShader != null) { glowPaint.setShader(glowShader); // Recipe matrix stays where it always was — inside the filter // chain, under the blur — so the bloom lands on GRADED pixels // (a monochrome recipe glows white, not colour). const glowBlur = Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null); glowPaint.setImageFilter( baseColorFilter != null ? Skia.ImageFilter.MakeCompose(glowBlur, Skia.ImageFilter.MakeColorFilter(baseColorFilter, null)) : glowBlur ); canvas.drawRect(Skia.XYWHRect(0, 0, width, frameTexture.height()), glowPaint); } else { // GLOW_SKSL unavailable: old per-channel curve (composed on TOP of // the recipe matrix, the order that keeps a monochrome look white). const hiMatrix = [ 2.5, 0, 0, 0, -1.5, 0, 2.5, 0, 0, -1.5, 0, 0, 2.5, 0, -1.5, 0, 0, 0, 1, 0, ]; // Order is load-bearing (see exportEngine pass 5b): chain both as // image filters so the highlight selection runs BEFORE the blur. const selection = Skia.ImageFilter.MakeColorFilter( baseColorFilter != null ? Skia.ColorFilter.MakeCompose(Skia.ColorFilter.MakeMatrix(hiMatrix), baseColorFilter) : Skia.ColorFilter.MakeMatrix(hiMatrix), null ); glowPaint.setImageFilter( Skia.ImageFilter.MakeCompose( Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null), selection ) ); if (fullShader != null) { glowPaint.setShader(fullShader); canvas.drawRect(Skia.XYWHRect(0, 0, width, frameTexture.height()), glowPaint); } else { canvas.drawImage(frameTexture, 0, 0, glowPaint); } } } }; // 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.getDirty(); const ftW = frameTexture.width(); const ftH = frameTexture.height(); const orient = frame.orientation; const bandActive = feed[0] > 0; if (!bandActive) { drawFullFrame(); return; } 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; // Framed-window pinch/drag (feed[6..8] = z + the crop centre as a // fraction of the window): show that slice of the cover plane — the // same crop the export takes (frameWindowZoom). The centre is in VIEW // axes, so it goes through the same out-space -> raw mapping as the // window rect (toRaw); handing it straight to the raw plane swapped // the axes whenever the sensor is rotated. With z at 1 the centre is // forced to the middle and this collapses to the plain draw above. const z = feed[6] > 1.001 ? feed[6] : 1; const half = 0.5 / z; const cu = Math.min(1 - half, Math.max(half, feed[7])); const cv = Math.min(1 - half, Math.max(half, feed[8])); const cX = X0 + cu * (X1 - X0); const cY = Y0 + cv * (Y1 - Y0); const hX = (X1 - X0) / (2 * z); const hY = (Y1 - Y0) / (2 * z); const b0 = toRaw(cX - hX, cY - hY); const b1 = toRaw(cX + hX, cY + hY); const zwX = (Math.min(b0[0], b1[0]) - dx) / s; const zwY = (Math.min(b0[1], b1[1]) - dy) / s; const k = s * z; // 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(u0 - k * zwX, v0 - k * zwY); canvas.scale(k, k); drawFullFrame(); canvas.restore(); }); frame.dispose(); }, [colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync, toneFailLogged, feedRectSync, frameBeatSync] ); // NOISE REDUCTION below 0 puts grain back instead of smoothing it away, so a // negative NR and GRAIN share one overlay (capped at 10 → 0.5), exactly as // the export pass does. const grainOpacity = Math.min(10, Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))) / 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 }); // Framed photo zoom survives a tab switch to WATERMARK: the mark editor // grabs a touch only when it lands on a mark box (see onLibTouchStart), so // the photo no longer has to be reset to 1x for the marks to stay draggable. // A crop starts from the whole picture inside its band/keep-rect, so entering // one — or switching to another ratio — drops the previous zoom/pan. Inside // the crop the photo can be pinched/dragged again to pick the framing. useEffect(() => { if (libCropActive) setLibZoom({ s: 1, tx: 0, ty: 0 }); }, [libCropActive, cropRatio]); // FRAME-tab PREVIEW zoom: while that tab is open two fingers magnify the // whole view (frame + photo + marks together) so a watermark can be placed // and sized against a big picture. Preview only — the export reads libZoom, // never this — and leaving the tab (a mode switch closes it) drops it to 1x. const [viewZoom, setViewZoom] = useState({ s: 1, tx: 0, ty: 0 }); // Magnified preview (FRAME tab only): one finger then drags the picture // inside the viewport, so it is the pan that pairs with the pinch above. const viewZoomed = frameTabActive && viewZoom.s > 1.01; // Keep the scaled view covering the viewport: no edge may pull inside it. const clampViewZoom = (z: { s: number; tx: number; ty: number }) => { const s = clampZoom(z.s); return { s, tx: Math.min(0, Math.max(-(s - 1) * vw, z.tx)), ty: Math.min(0, Math.max(-(s - 1) * vh, z.ty)), }; }; const viewPinchRef = useRef<{ d: number; s: number; tx: number; ty: number; fx: number; fy: number } | null>(null); useEffect(() => { if (!frameTabActive) setViewZoom({ s: 1, tx: 0, ty: 0 }); }, [frameTabActive]); const pinchRef = useRef<{ d: number; s: number; tx: number; ty: number; fx: number; fy: number } | null>(null); const pinchUsedRef = useRef(false); // Framed-window drag: anchor at touch-down (finger + transform origin), so a // move never accumulates rounding across a slow drag. const panRef = useRef<{ x: number; y: number; px: number; py: number; tx: number; ty: number } | null>(null); const panUsedRef = 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]); // Fixed-ratio CROP: the keep-rectangle is the largest centred rect of that // shape inside the photo, and the photo is NOT re-scaled to it — the whole // picture stays on screen (contain) with the band marking what the export // keeps, so the user can pinch/drag the picture under the band to choose the // framing. Sizing the band off the photo (not the viewport) keeps it inside // the picture at 1x, so the band never shows letterbox either. const libCropBand = useMemo(() => { if (cropRatio === 'none' || cropRatio === 'free' || !libCropActive || !imageFitRect) return null; const r = CROP_W_H[cropRatio]; let dw = imageFitRect.dw; let dh = dw / r; if (dh > imageFitRect.dh) { dh = imageFitRect.dh; dw = dh * r; } return { dx: imageFitRect.dx + (imageFitRect.dw - dw) / 2, dy: imageFitRect.dy + (imageFitRect.dh - dh) / 2, dw, dh, }; }, [libCropActive, cropRatio, imageFitRect]); // Committed crop geometry: the band's contents scaled up so the ratio fills // the viewer (centred, black around it). Frame, marks, grain and vignette all // hug the band from inside the photo group, so one extra matrix over that // group carries them along; the touch layer takes the same transform so its // canvas coordinates still land on the pixel under the finger. const libCropView = useMemo(() => { if (!libCropApplied || !libCropBand) return null; const k = Math.min(vw / libCropBand.dw, vh / libCropBand.dh); const w = libCropBand.dw * k; const h = libCropBand.dh * k; return { k, ox: (vw - w) / 2 - k * libCropBand.dx, oy: (vh - h) / 2 - k * libCropBand.dy, rect: { x: (vw - w) / 2, y: (vh - h) / 2, w, h }, }; }, [libCropApplied, libCropBand, vw, vh]); // Photo display rect + draw fit in the plain library viewer: the full photo // is contain-fitted to the screen (fill into its own aspect rect — no // distortion, identical to the old fullscreen contain). The ratio band only // shapes the CAMERA composition; a library export is the photo as framed. const dispRect = regionBand ? { dx: region.x, dy: region.y, dw: region.w, dh: region.h } : imageFitRect; const dispFit: 'cover' | 'fill' = regionBand ? 'cover' : 'fill'; // Rect the frame border / cinematic bars / grain hug: the ratio band on the // camera, the contain rect of the loaded library photo, fullscreen otherwise. // ponytail: dispRect speaks {dx,dy,dw,dh}; every reader of frameRect // wants {x,y,w,h}. Normalise here or the native Recorder gets undefined // width/height and hard-crashes ("Invalid float prop value received"). // FREE crop keeps the whole photo on screen (black masks mark the keep-zone), // so the keep-rectangle is its own rect: the frame border, the vignette and // the grain hug what the export will actually keep. const freeCropPx = useMemo(() => { if (!libFreeCrop || !imageFitRect) return null; return { x: imageFitRect.dx + cropRect.x * imageFitRect.dw, y: imageFitRect.dy + cropRect.y * imageFitRect.dh, w: cropRect.w * imageFitRect.dw, h: cropRect.h * imageFitRect.dh, }; }, [libFreeCrop, imageFitRect, cropRect]); // Keep-rectangle drawn on screen: the draggable FREE box or the fixed band. const cropWinPx = libCropBand ? { x: libCropBand.dx, y: libCropBand.dy, w: libCropBand.dw, h: libCropBand.dh } : freeCropPx; // What the screen-space dark mask surrounds: the same band, or the rect the // committed crop was scaled onto. const cropScreenPx = libCropView ? libCropView.rect : cropWinPx; const frameRect = cropWinPx ? cropWinPx : mode === 'library' && dispRect ? { x: dispRect.dx, y: dispRect.dy, w: dispRect.dw, h: dispRect.dh } : region; // 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. // The frame keys off the ORIGINAL (pre-rotation) source: rotating the print // inside the frame must never resize the frame itself. const framePhotoAspect = loadedImage && loadedImage.width() > 0 && loadedImage.height() > 0 ? loadedImage.width() / loadedImage.height() : 0; const polaroidAspect = ratioWH ?? (mode === 'library' && framePhotoAspect > 0 ? framePhotoAspect : 3 / 4); // Photo draw fit inside the polaroid window: cover when the window aspect // (ratio) differs from the source photo, fill otherwise (aspects match). A // print rotated inside the frame no longer matches the (un-rotated) window // aspect, so it is contain-fitted — letterboxed inside the window, never // stretched, and the card keeps its dimensions. const polaroidFit: 'cover' | 'contain' | 'fill' = ratioWH ? 'cover' : photoRotation === 90 || photoRotation === 270 ? 'contain' : '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', wallLandscape); }, [selectedFrame, vw, vh, wallLandscape]); // Framed photo window whose content the user may pinch/drag: polaroid card // window or wall-frame opening. Null for plain frames (their whole // composition region is the photo) and for the camera's ratio bands (the // export never crops those through a window). const zoomWindowRect = (): { x: number; y: number; w: number; h: number } | null => { // Fixed-ratio crop: the band is the window the picture is positioned in. if (libCropBand) return { x: libCropBand.dx, y: libCropBand.dy, w: libCropBand.dw, h: libCropBand.dh }; if (polaroidGeo) return polaroidGeo.window; if (selectedFrame === 'wallframe' && wallGeo && wallImage) return wallGeo.window; return null; }; // Framed photo windows move when the deck grows (slider) or shrinks: the // photo content is scaled about its own top-left, so a plain move of the // window under a zoomed photo slides the visible crop away from the card. // Re-anchor the transform when the window rect changes so the SAME part of // the photo stays inside the frame (same u/v math the export uses). const winRectRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null); useEffect(() => { const win = zoomWindowRect(); const prev = winRectRef.current; winRectRef.current = win ? { ...win } : null; if (!prev || !win) return; if (prev.x === win.x && prev.y === win.y && prev.w === win.w && prev.h === win.h) return; setLibZoom((z) => { if (z.s <= 1.001) return z; const tlx = z.tx + (z.s - 1) * prev.x; const tly = z.ty + (z.s - 1) * prev.y; const u = (prev.w / 2 - tlx) / (z.s * prev.w); const v = (prev.h / 2 - tly) / (z.s * prev.h); return { s: z.s, tx: win.w / 2 - u * z.s * win.w - (z.s - 1) * win.x, ty: win.h / 2 - v * z.s * win.h - (z.s - 1) * win.y, }; }); }); // Camera: picking another frame — or leaving the camera — starts from the // whole picture inside its window (the library has its own reset rules). useEffect(() => { if (mode === 'camera') setLibZoom({ s: 1, tx: 0, ty: 0 }); }, [mode, selectedFrame]); // Clamp pan/zoom so the photo always keeps COVERING the window: the framed // opening (photo cover-drawn into it) or the ratio-crop band (photo // contain-drawn over the whole viewer). Transform is P' = s*P + t; the rect // the photo is drawn into, [dx,dx+dw], must land over [x, x+w], i.e. // t in [x + w - s*(dx+dw), x - s*dx] (y likewise). At 1x the polaroid/wall // case collapses to t = 0 (the photo already fills its opening exactly), // while a crop band still allows sliding along the overflowing axis. const clampZoomToWindow = (s: number, tx: number, ty: number) => { const win = zoomWindowRect(); if (!win) return { s, tx, ty }; // plain library/camera: unchanged const src = libCropBand && imageFitRect ? { x: imageFitRect.dx, y: imageFitRect.dy, w: imageFitRect.dw, h: imageFitRect.dh } : win; const cl = (v: number, lo: number, hi: number) => lo > hi ? (lo + hi) / 2 : Math.min(hi, Math.max(lo, v)); return { s, tx: cl(tx, win.x + win.w - s * (src.x + src.w), win.x - s * src.x), ty: cl(ty, win.y + win.h - s * (src.y + src.h), win.y - s * src.y), }; }; // 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(() => { const fz = frameZoomRef.current; feedRectSync.setBlocking( cameraPhotoRect ? [ 1, vw / vh, cameraPhotoRect.x / vw, cameraPhotoRect.y / vh, cameraPhotoRect.w / vw, cameraPhotoRect.h / vh, fz?.s ?? 1, fz?.u ?? 0.5, fz?.v ?? 0.5, ] : [0, 0, 0, 0, 0, 0, 0, 0.5, 0.5] ); }, [cameraPhotoRect, vw, vh, feedRectSync, libZoom]); // Camera watchdog: the Xiaomi wedges the preview — horizontal banding then // black, while the native session still logs OPEN (no error, no onStopped) // and only a full app restart revives it (a flip = session reconfigure does // NOT clear the wedged Skia state). Every 3s, while the app is foregrounded // in camera mode and a session has started at least once: remount the whole // SkiaCamera (unmount clears the lib surface cache, fresh mount == restart) // when the session reports inactive, or when the frame heartbeat has not // advanced for two ticks (~6s) — frame delivery is dead even though the // session claims to be OPEN. Budgeted to 3 restarts per successful start so // a genuinely broken session logs out instead of restart-looping forever. const remountCameraSession = useCallback(() => { if (bouncePendingRef.current) return; bouncePendingRef.current = true; setAppActive(false); setTimeout(() => { // Fresh mount while inactive, so the new instance starts clean and // CLOSED; reopen below once it exists. setCameraMountNonce((n) => n + 1); setTimeout(() => { bouncePendingRef.current = false; // Never force the camera on while the app is backgrounded. if (AppState.currentState === 'active') setAppActive(true); }, 300); }, 300); }, []); useEffect(() => { if (mode !== 'camera') return; let lastBeat = -1; let stallTicks = 0; const id = setInterval(() => { const beat = frameBeatSync.getDirty(); if (beat === lastBeat) stallTicks++; else { lastBeat = beat; stallTicks = 0; } // Backgrounding pauses the session on purpose — never watchdog that. if (!startedOnceRef.current || !appActiveRef.current || ultraWideOnRef.current) return; // Two stale ticks give an in-flight (flip/resume/remount) restart time // to land. if (stallTicks < 2) return; const reason = cameraActiveRef.current ? 'no frames for ~6s' : 'session inactive'; if (watchdogBudgetRef.current >= 3) { console.warn('[watchdog] camera looks dead (' + reason + ') — restart budget exhausted, giving up'); return; } watchdogBudgetRef.current++; console.warn('[watchdog] remounting camera session: ' + reason); lastBeat = -1; stallTicks = 0; remountCameraSession(); }, 3000); return () => clearInterval(id); }, [mode, frameBeatSync, remountCameraSession]); // ---- Custom text watermark: photo area, drag math, library overlay -------- // Armed = CUSTOM chip ON + non-empty text + live position callback. While // armed, touches on the image place/move the text (see the wm branches in the // camera + library gesture handlers); this is the only way to position it. // Drawn in both modes whenever the chip is ON with text; TOUCHED only while // the WATERMARK panel is open (wmEditing) — see the prop comment. const wmVisible = !!(customWm.enabled && customWm.text.trim().length > 0); const wmArmed = !!(wmVisible && wmEditing && onWmPositionChange); const [wmDragPos, setWmDragPos] = useState<{ x: number; y: number } | null>(null); // GPS lines: same deal, but they have no text editor — armed whenever the // panel is open and the mark is drawn. const gpsWmVisible = !!(useGeotag && gpsInfo && customFont && iconFont); const gpsWmArmed = !!(gpsWmVisible && wmEditing && onGpsWmChange); const [gpsDragPos, setGpsDragPos] = useState<{ x: number; y: number } | null>(null); // Photo/window area the 0..1 fractions map onto — identical rule to // renderGPSWatermark so both marks share the user-visible coordinate space. const wmArea = () => { // The FRAME, not just the photo window: the mark may be dragged anywhere // on the card / frame (the flat bottom deck is where a caption belongs). // A framed export's canvas IS that frame, so the same 0..1 fractions // carry over to the burn-in. const box = polaroidGeo?.card ?? wallGeo?.frame ?? null; if (box) return { dx: box.x, dy: box.y, dw: box.w, dh: box.h }; if (regionBand) return { dx: region.x, dy: region.y, dw: region.w, dh: region.h }; if (mode === 'library' && imageFitRect) return imageFitRect; return { dx: 0, dy: 0, dw: vw, dh: vh }; }; // The drawn mark's baseline origin. Everything that has to agree with the // glyphs — the render, the double-tap box, the export — goes through here, // and the ROTATE chip spins around this same point. const wmAnchor = () => { const area = wmArea(); const fracX = wmDragPos?.x ?? customWm.x; const fracY = wmDragPos?.y ?? customWm.y; return { ax: area.dx + fracX * area.dw, ay: area.dy + fracY * area.dh + Math.round(wmFontSize * 0.35), }; }; // The GPS block's TOP-LEFT corner (the two lines grow right and down from // there) — the same 0..1 fractions the export burns in, so preview and file // agree. Defaults are the old fixed top-left look. const gpsAnchor = () => { const area = wmArea(); const fracX = gpsDragPos?.x ?? gpsWm?.x ?? 0.05; const fracY = gpsDragPos?.y ?? gpsWm?.y ?? 0.1; return { ax: area.dx + fracX * area.dw, ay: area.dy + fracY * area.dh, iconSize: gpsIconSize, }; }; const wmRad = (wmRotation * Math.PI) / 180; const wmFontSize = Math.round(vw * 0.032 * (customWm.size ?? 1)); const wmColor = customWm.color ?? '#f59e0b'; // FONT chip: an OS family resolved through the platform font manager; no // family picked -> the bundled face customFont was built from, re-sized to // the current SIZE so one code path serves both. const wmFont = useMemo(() => { const face = customWm.font ? Skia.FontMgr.System().matchFamilyStyle(customWm.font, FontStyle.Normal) : customFont?.getTypeface() ?? null; return face ? Skia.Font(face, wmFontSize) : null; }, [customWm.font, wmFontSize, customFont]); const wmFracFromXY = (x: number, y: number) => { const area = wmArea(); const dw = area.dw || 1; const dh = area.dh || 1; return { x: Math.max(0, Math.min(1, (x - area.dx) / dw)), y: Math.max(0, Math.min(1, (y - area.dy) / dh)), }; }; // placeWm/commitWm are called back-to-back inside one gesture handler, so // reading the wmDragPos state would commit the PREVIOUS render's value (a // tap with no move events committed nothing at all). Mirror it in a ref. const wmDragPosRef = useRef<{ x: number; y: number } | null>(null); const gpsDragPosRef = useRef<{ x: number; y: number } | null>(null); // The mark a running gesture grabbed, decided at grant time (the box under // the first touch) so a drag keeps hold of the mark it started on. const wmDragTgtRef = useRef<'custom' | 'gps' | null>(null); const setWmFrac = (frac: { x: number; y: number }, tgt: 'custom' | 'gps') => { if (tgt === 'custom') { if (!wmArmed) return; wmDragPosRef.current = frac; setWmDragPos(frac); return; } if (!gpsWmArmed) return; gpsDragPosRef.current = frac; setGpsDragPos(frac); }; const placeWm = (x: number, y: number, tgt: 'custom' | 'gps') => setWmFrac(wmFracFromXY(x, y), tgt); // The mark drags RELATIVELY: it keeps its spot when the finger lands and only // travels by the finger's delta. Placing it AT the touch (the old behavior) // yanked the mark under the finger, so a pinch that started on it threw it // across the frame. Anchor its fraction + the touch-down point here. const wmDragRef = useRef<{ tgt: 'custom' | 'gps'; fx: number; fy: number; px: number; py: number; moved: boolean; } | null>(null); const beginWmDrag = (tgt: 'custom' | 'gps', x: number, y: number) => { const frac = tgt === 'custom' ? { x: wmDragPosRef.current?.x ?? customWm.x, y: wmDragPosRef.current?.y ?? customWm.y } : { x: gpsDragPosRef.current?.x ?? gpsWm?.x ?? 0.05, y: gpsDragPosRef.current?.y ?? gpsWm?.y ?? 0.1, }; wmDragRef.current = { tgt, fx: frac.x, fy: frac.y, px: x, py: y, moved: false }; }; const moveWmDrag = (x: number, y: number) => { const d = wmDragRef.current; if (!d) return; const area = wmArea(); setWmFrac( { x: Math.max(0, Math.min(1, d.fx + (x - d.px) / (area.dw || 1))), y: Math.max(0, Math.min(1, d.fy + (y - d.py) / (area.dh || 1))), }, d.tgt ); }; const commitWm = () => { const pos = wmDragPosRef.current; wmDragPosRef.current = null; setWmDragPos(null); if (wmArmed && onWmPositionChange && pos) onWmPositionChange(pos.x, pos.y); const gpos = gpsDragPosRef.current; gpsDragPosRef.current = null; setGpsDragPos(null); if (gpsWmArmed && onGpsWmChange && gpos) onGpsWmChange(gpos.x, gpos.y, gpsWm?.size ?? 1); }; // Two fingers on a mark: scale THAT mark (custom SIZE / GPS mark size) // instead of zooming the photo. 0.4..4x keeps it on the card. const wmPinchRef = useRef<{ tgt: 'custom' | 'gps'; d: number; size: number } | null>(null); const wmSizeOf = (tgt: 'custom' | 'gps') => tgt === 'custom' ? customWm.size ?? 1 : gpsWm?.size ?? 1; const applyWmScale = (tgt: 'custom' | 'gps', size: number) => { const s = Math.max(0.4, Math.min(4, size)); if (tgt === 'custom') onWmSizeChange?.(s); else onGpsWmChange?.(gpsWm?.x ?? 0.05, gpsWm?.y ?? 0.1, s); }; // Bounding box of the drawn mark (same anchor maths as renderCustomWatermark) // so a double-tap ON the text re-opens the editor instead of moving it. const wmBox = () => { if (!wmVisible || !wmFont) return null; const { ax, ay } = wmAnchor(); const w = Math.max(wmFont.measureText(customWm.text.trim()).width, wmFontSize * 2); return { x: ax, y: ay - wmFontSize, w, h: Math.round(wmFontSize * 1.4) }; }; // A rotated mark needs the tap un-spun around the same anchor, otherwise // the box stops covering the visible glyphs. const boxHitAt = ( box: { x: number; y: number; w: number; h: number } | null, x: number, y: number, origin: { x: number; y: number } ) => { if (!box) return false; if (wmRotation) { const c = Math.cos(-wmRad); const s = Math.sin(-wmRad); const dx = x - origin.x; const dy = y - origin.y; x = origin.x + dx * c - dy * s; y = origin.y + dx * s + dy * c; } return ( x >= box.x - 8 && x <= box.x + box.w + 8 && y >= box.y - 8 && y <= box.y + box.h + 8 ); }; const wmBoxHit = ( box: { x: number; y: number; w: number; h: number } | null, x: number, y: number ) => boxHitAt(box, x, y, wmAnchor()); // The GPS block covers both lines out to the widest one (the timestamp). const gpsBox = () => { const { ax, ay, iconSize } = gpsAnchor(); return { x: ax, y: ay, w: Math.round(iconSize * 8.5), h: Math.round(iconSize * 2.6) }; }; const gpsBoxHit = (x: number, y: number) => boxHitAt(gpsBox(), x, y, gpsAnchor()); // Which mark a touch is on. A box hit always wins; with neither box hit the // lone armed mark takes the touch, so a tap anywhere still places it. const wmTargetAt = (x: number, y: number): 'custom' | 'gps' | null => { const custom = wmArmed && wmBoxHit(wmBox(), x, y); const gps = gpsWmArmed && gpsBoxHit(x, y); if (custom) return 'custom'; if (gps) return 'gps'; if (wmArmed) return 'custom'; if (gpsWmArmed) return 'gps'; return null; }; // Touch coordinates are CANVAS coordinates: RN hands locationX/Y back with the // touch layer's own transform undone, and that layer now carries the very same // matrix as the Skia canvas (see touchZoomStyle), so a mark hit-test or a drag // line reads the touch straight off — no inverse at any view zoom. // The armed mark a two-finger pinch is ON. Boxes only: a pinch on empty space // must not steal the gesture from the FRAME tab's view zoom. const pinchMarkAt = (x: number, y: number): 'custom' | 'gps' | null => { if (!wmEditing) return null; if (wmArmed && wmBoxHit(wmBox(), x, y)) return 'custom'; if (gpsWmArmed && gpsBoxHit(x, y)) return 'gps'; return null; }; // Two fingers on the FRAME tab magnify the whole VIEW about their midpoint // (the photo zoom's maths, aimed at the view instead). const applyViewPinch = (ts: { locationX: number; locationY: number }[]) => { const [a, b] = ts; const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY); const mx = (a.locationX + b.locationX) / 2; const my = (a.locationY + b.locationY) / 2; const p = viewPinchRef.current; if (!p) { viewPinchRef.current = { d, s: viewZoom.s, tx: viewZoom.tx, ty: viewZoom.ty, fx: mx, fy: my }; return; } const s = clampZoom((p.s * d) / Math.max(p.d, 1)); // f* is the canvas point the fingers pinch on: keep s*f + t constant with // the OLD s/t (at 1x this is the old fx*(1-k), now correct when zoomed too). setViewZoom(clampViewZoom({ s, tx: p.tx + p.fx * (p.s - s), ty: p.ty + p.fy * (p.s - s) })); }; // Library framed (polaroid/wall) returns reuse the SAME responder handlers as // plain library (see the framed JSX below): single-finger drags place/move // the text watermark while armed, and otherwise DRAG the photo inside the // framed window (same cover clamp as pinch, so the opening never shows the // card/mat through it); pinch + double-tap zoom that same repositioning. // The pan only bites once the photo is zoomed past 1x: at the cover scale it // already fills the opening exactly, so the clamp has nowhere to move. // FREE CROP gesture: grab an edge/corner to resize, the inside to move. Drag // maths runs in SOURCE fractions (0..1 of the photo) so the exported crop is // exactly the rectangle the user sees, independent of the display scale. const cropDragRef = useRef<{ mode: string; fx: number; fy: number; r: CropRect } | null>(null); const cropHitMode = (x: number, y: number): string | null => { const px = freeCropPx; if (!px) return null; const T = 28; if (x < px.x - T || x > px.x + px.w + T || y < px.y - T || y > px.y + px.h + T) return null; let m = ''; if (Math.abs(y - px.y) <= T) m += 't'; else if (Math.abs(y - (px.y + px.h)) <= T) m += 'b'; if (Math.abs(x - px.x) <= T) m += 'l'; else if (Math.abs(x - (px.x + px.w)) <= T) m += 'r'; return m || 'move'; }; const beginCropDrag = (e: any) => { const t = e.nativeEvent.touches?.[0] ?? e.nativeEvent; const fit = imageFitRect; if (!fit || !(fit.dw > 0 && fit.dh > 0)) return; const mode = cropHitMode(t.locationX, t.locationY); if (!mode) return; cropDragRef.current = { mode, fx: (t.locationX - fit.dx) / fit.dw, fy: (t.locationY - fit.dy) / fit.dh, r: cropRect, }; }; const moveCropDrag = (e: any) => { const d = cropDragRef.current; const fit = imageFitRect; if (!d || !fit || !(fit.dw > 0 && fit.dh > 0)) return; const t = e.nativeEvent.touches?.[0] ?? e.nativeEvent; const dfx = (t.locationX - fit.dx) / fit.dw - d.fx; const dfy = (t.locationY - fit.dy) / fit.dh - d.fy; const cl = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); let { x: rx, y: ry, w: rw, h: rh } = d.r; if (d.mode === 'move') { rx = cl(rx + dfx, 0, 1 - rw); ry = cl(ry + dfy, 0, 1 - rh); } else { if (d.mode.includes('l')) { const nx = cl(rx + dfx, 0, rx + rw - MIN_CROP_FRAC); rw += rx - nx; rx = nx; } if (d.mode.includes('r')) rw = cl(rw + dfx, MIN_CROP_FRAC, 1 - rx); if (d.mode.includes('t')) { const ny = cl(ry + dfy, 0, ry + rh - MIN_CROP_FRAC); rh += ry - ny; ry = ny; } if (d.mode.includes('b')) rh = cl(rh + dfy, MIN_CROP_FRAC, 1 - ry); } onCropRectChange?.({ x: rx, y: ry, w: rw, h: rh }); }; const onLibTouchStart = (e: any) => { if (libFreeCrop) { beginCropDrag(e); return; } const ts = e.nativeEvent.touches; if (ts.length === 1) startHold(ts[0].pageX, ts[0].pageY); else endHold(); panRef.current = ts.length === 1 ? { x: ts[0].locationX, y: ts[0].locationY, px: ts[0].pageX, py: ts[0].pageY, tx: viewZoomed ? viewZoom.tx : libZoom.tx, ty: viewZoomed ? viewZoom.ty : libZoom.ty, } : null; panUsedRef.current = false; if (ts.length === 1 && (wmArmed || gpsWmArmed)) { const { x, y } = { x: ts[0].locationX, y: ts[0].locationY }; const boxHit = (wmArmed && wmBoxHit(wmBox(), x, y)) || (gpsWmArmed && gpsBoxHit(x, y)); // Anything zoomed (photo or view): only a touch ON a mark takes the // gesture, the rest keeps panning / magnifying. At 1x the lone armed mark // takes any touch so a plain tap still places it. wmDragTgtRef.current = (libZoom.s <= 1.01 && viewZoom.s <= 1.01) || boxHit ? wmTargetAt(x, y) : null; if (wmDragTgtRef.current) beginWmDrag(wmDragTgtRef.current, x, y); } else wmDragTgtRef.current = null; if (ts.length >= 2) { const [a, b] = ts; const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY); const mx = (a.locationX + b.locationX) / 2; const my = (a.locationY + b.locationY) / 2; pinchUsedRef.current = true; lastTapRef.current = null; if (wmEditing) { // WATERMARK tab open: a two-finger gesture ON a mark scales it. One // that lands off the marks falls through to the FRAME tab's view zoom // (seeded in the move handler), so it never zooms the photo either. const mi = { x: mx, y: my }; const tgt = pinchMarkAt(mi.x, mi.y); wmPinchRef.current = tgt ? { tgt, d, size: wmSizeOf(tgt) } : null; pinchRef.current = null; return; } // FRAME tab open: the pinch magnifies the VIEW, not the photo — the // export keeps the framing the user sees at 1x. if (frameTabActive) { pinchRef.current = null; return; } pinchRef.current = { d, s: libZoom.s, tx: libZoom.tx, ty: libZoom.ty, fx: mx, fy: my }; } }; const onLibTouchMove = (e: any) => { if (libFreeCrop) { moveCropDrag(e); return; } const ts = e.nativeEvent.touches; if (ts.length === 1) holdNoteMove(ts[0].pageX, ts[0].pageY); else endHold(); // WATERMARK tab open: two fingers scale the mark under them (or do // nothing) — the photo itself only zooms once the editor is closed. if (ts.length >= 2 && wmEditing) { // The second finger normally lands on its own, so the START handler never // saw the pair and wmPinchRef is still null -- which is why pinching a // mark worked on the camera (that path seeds here) but not in the library // editor. Seed the baseline the same way. let p = wmPinchRef.current; if (!p) { const mi = { x: (ts[0].locationX + ts[1].locationX) / 2, y: (ts[0].locationY + ts[1].locationY) / 2, }; const tgt = pinchMarkAt(mi.x, mi.y); if (tgt) { p = { tgt, d: Math.hypot(ts[0].locationX - ts[1].locationX, ts[0].locationY - ts[1].locationY), size: wmSizeOf(tgt), }; wmPinchRef.current = p; } } if (p) { const d = Math.hypot(ts[0].locationX - ts[1].locationX, ts[0].locationY - ts[1].locationY); applyWmScale(p.tgt, (p.size * d) / Math.max(p.d, 1)); pinchUsedRef.current = true; return; } // Off the marks on the FRAME tab: fall through to the view zoom below. } // FRAME tab: two fingers magnify the whole view — frame, photo and marks // together — so a watermark can be dragged against a big picture. Preview // only; nothing export-facing reads viewZoom. if (ts.length >= 2 && frameTabActive) { applyViewPinch(ts); pinchUsedRef.current = true; return; } // Watermark armed + one finger + unzoomed: the drag moves the mark (not the // photo). Zoomed-in keeps pinch/pan so the user can inspect detail first. if (ts.length === 1 && wmDragTgtRef.current) { const t = { x: ts[0].locationX, y: ts[0].locationY }; const d = wmDragRef.current; if (d && (Math.abs(t.x - d.px) > 6 || Math.abs(t.y - d.py) > 6)) { d.moved = true; } if (d?.moved) moveWmDrag(t.x, t.y); return; } // FRAME tab, magnified view: one finger drags the picture inside the // viewport instead of the framed photo (preview only). if (ts.length === 1 && !pinchRef.current && viewZoomed) { const t = ts[0]; const pan0 = panRef.current; if (pan0) { // pageX/pageY: absolute screen dp, immune to the view transform, so the // picture tracks the finger 1:1 (locationX is scaled by the matrix). const dx = t.pageX - pan0.px; const dy = t.pageY - pan0.py; if (Math.abs(dx) > 6 || Math.abs(dy) > 6) panUsedRef.current = true; if (panUsedRef.current) { setViewZoom(clampViewZoom({ s: viewZoom.s, tx: pan0.tx + dx, ty: pan0.ty + dy })); } } return; } // One finger, no pinch running: drag the photo inside the framed window // (polaroid card / wall-frame opening). Goes through the same clamp as // pinch, so the opening always stays covered by the picture. if (ts.length === 1 && !pinchRef.current && zoomWindowRect()) { const t = ts[0]; const pan0 = panRef.current; if (pan0) { const dx = t.locationX - pan0.x; const dy = t.locationY - pan0.y; if (Math.abs(dx) > 6 || Math.abs(dy) > 6) panUsedRef.current = true; if (panUsedRef.current) { setLibZoom(clampZoomToWindow(libZoom.s, pan0.tx + dx, pan0.ty + dy)); } } return; } 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; const next = clampZoomToWindow( s, p.fx - k * (p.fx - p.tx), p.fy - k * (p.fy - p.ty) ); setLibZoom(next); } }; const onLibTouchEnd = (e: any) => { if (libFreeCrop) { cropDragRef.current = null; return; } const peeked = endHold(); const wasPinch = pinchUsedRef.current; const wasPan = panUsedRef.current; // Decided at touch-down; the reset below would lose it. const wmTgt = wmDragTgtRef.current; const wmMoved = !!wmDragRef.current?.moved; pinchRef.current = null; wmPinchRef.current = null; wmDragTgtRef.current = null; wmDragRef.current = null; viewPinchRef.current = null; pinchUsedRef.current = false; panRef.current = null; panUsedRef.current = false; // A drag is neither a tap nor the first half of a double-tap. if (wasPan || wmMoved || peeked) lastTapRef.current = null; if (wasPinch || wasPan || peeked) return; // a double-tap is always a single finger const x = e.nativeEvent.locationX; const y = e.nativeEvent.locationY; // Mark drawn but its editor CLOSED: a double-tap ON the mark re-opens the // editor (and the placement layer with it). A single tap is NOT consumed — // this block records nothing, so the viewer rules below still run and the // photo keeps pinch / double-tap zoom / pan. if (!wmArmed && !gpsWmArmed && (wmVisible || gpsWmVisible) && libZoom.s <= 1.01) { const prev = lastTapRef.current; if ( prev && Date.now() - prev.t < 350 && Math.hypot(x - prev.x, y - prev.y) < 50 && (wmBoxHit(wmBox(), x, y) || gpsBoxHit(x, y)) ) { lastTapRef.current = null; onWmEdit?.(); return; } } // Watermark armed: a tap places + commits the text and skips the double-tap // zoom bookkeeping (double-tap zoom is disabled while placing). if ((wmArmed || gpsWmArmed) && ((libZoom.s <= 1.01 && viewZoom.s <= 1.01) || wmTgt)) { const now = Date.now(); const prev = lastTapRef.current; lastTapRef.current = { x, y, t: now }; // Second tap ON a mark (keyboard hidden, still placing): back to the // editor — the mark is only embedded by ACCEPT in the panel. if ( prev && now - prev.t < 350 && Math.hypot(x - prev.x, y - prev.y) < 50 && (wmBoxHit(wmBox(), x, y) || gpsBoxHit(x, y)) ) { lastTapRef.current = null; onWmEdit?.(); return; } // A drag has already moved the mark where the finger left it; only a // plain tap drops it at the tapped point. if (!wmMoved) placeWm(x, y, wmTgt ?? wmTargetAt(x, y) ?? 'custom'); commitWm(); return; } 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 — the framed // window when one is showing (polaroid/wall), the composition region // under a ratio band, or the contain rect — so a double-tap that lands // in the black letterbox cannot fling the photo away. let fx = x; let fy = y; const win = zoomWindowRect(); const fit = 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 } : 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; // zoom into the tapped point, then clamp so the photo keeps covering // the framed window (plain library keeps the raw focal transform). return clampZoomToWindow(s, fx - s * fx, fy - s * fy); }); } }; // 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] ); // Committed crop: one more matrix ON TOP of the photo group's zoom/pan, so // the band's framed contents land on the viewer rect — outer·inner = // [ks, 0, k·tx + s·ox, 0, ks, k·ty + s·oy, 0, 0, 1]. const libViewMatrix = useMemo( () => libCropView ? [ libZoom.s * libCropView.k, 0, libZoom.s * libCropView.ox + libCropView.k * libZoom.tx, 0, libZoom.s * libCropView.k, libZoom.s * libCropView.oy + libCropView.k * libZoom.ty, 0, 0, 1, ] : libZoomMatrix, [libCropView, libZoom, libZoomMatrix] ); // FRAME-tab view zoom as a Skia matrix, drawn as an OUTER group around a // branch's whole canvas. Preview only: nothing export-facing reads it. const viewZoomMatrix = useMemo( () => [viewZoom.s, 0, viewZoom.tx, 0, viewZoom.s, viewZoom.ty, 0, 0, 1], [viewZoom] ); // Same transform on the touch layer, and that is the point of it: RN scales // about the view's CENTRE by default, which dragged the layer's local space // off the canvas — and every mark hit-test with it — as soon as the view was // zoomed. Pinning the origin to the top left makes the layer map exactly like // the Skia matrix the canvas draws with (P' = s·P + t), which is also what // lets the gesture handlers use their coordinates as canvas coordinates. const touchZoomStyle = (z: { s: number; tx: number; ty: number }) => z.s === 1 && z.tx === 0 && z.ty === 0 ? undefined : { transformOrigin: 'top left', transform: [{ translateX: z.tx }, { translateY: z.ty }, { scale: z.s }], }; // The view zoom composes OVER the committed crop (outer·inner), exactly like // the canvas draws it. const libCropTouchStyle = touchZoomStyle( libCropView ? { s: viewZoom.s * libCropView.k, tx: viewZoom.s * libCropView.ox + viewZoom.tx, ty: viewZoom.s * libCropView.oy + viewZoom.ty, } : viewZoom ); // Export-facing summary of the current framed-window photo reposition. The // gesture state (libZoom) is canvas-absolute; u/v convert it to the fraction // of the photo's cover plane sitting at the WINDOW CENTER — layout units the // export engine can apply at its own pixel size (the Skia render draws the // source sub-rectangle that stays visible through the frame's opening). The // handle method reads this ref because useImperativeHandle captures once. const frameZoomRef = useRef<{ s: number; u: number; v: number } | null>(null); frameZoomRef.current = (() => { const win = zoomWindowRect(); // A ratio crop reports through cropRatioRectRef instead (its band is the // window here, and one export is only ever one kind of crop). if (libCropActive) return null; if (!win || libZoom.s <= 1.001) return null; const tlx = libZoom.tx + (libZoom.s - 1) * win.x; const tly = libZoom.ty + (libZoom.s - 1) * win.y; return { s: libZoom.s, u: (win.w / 2 - tlx) / (libZoom.s * win.w), v: (win.h / 2 - tly) / (libZoom.s * win.h), }; })(); // Fixed-ratio CROP, for export: the export crops to its own band, but the // user may have pushed the picture around inside it, so report the part of // the photo the band is currently SHOWING as fractions of the // (post-rotation) source — the same currency as the FREE rectangle, so the // export engine needs to know nothing about bands or zoom. const cropRatioRectRef = useRef(null); cropRatioRectRef.current = (() => { if (!libCropBand || !imageFitRect) return null; const s = Math.max(1, libZoom.s); const fx = (x: number) => ((x - libZoom.tx) / s - imageFitRect.dx) / imageFitRect.dw; const fy = (y: number) => ((y - libZoom.ty) / s - imageFitRect.dy) / imageFitRect.dh; const cl = (v: number) => Math.min(1, Math.max(0, v)); const x1 = cl(fx(libCropBand.dx)); const y1 = cl(fy(libCropBand.dy)); const x2 = cl(fx(libCropBand.dx + libCropBand.dw)); const y2 = cl(fy(libCropBand.dy + libCropBand.dh)); return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 }; })(); // Border/bars scale with the visible photo rect so a framed export stays // proportional to the picture (band on the camera, contain rect in library). const borderWidth = Math.min(frameRect.w, frameRect.h) * 0.05; const barHeight = frameRect.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 visible photo rect (frameRect), so it always sits // at the picture's edge — including the letterboxed library viewer. return ( ); } if (selectedFrame === 'cinematic') { // Letterbox bars hug the visible photo rect's top/bottom edges. return ( ); } return null; }; const renderCustomWatermark = () => { if (!wmVisible || !wmFont) return null; // Baseline = dragged anchor + ~0.35em drop (the grabbed anchor reads as the // text's visual center). Must match exportEngine step 8's formula. const { ax, ay } = wmAnchor(); return ( ); }; // Vignetting (FX): darken the four corners of the PHOTO area. A radial // gradient (clear centre → black corners) sized to the given rect, mirroring // the export pass (exportEngine 6b) so preview and file match. Drawn on the // photo only — never on a polaroid/wall mat. const vignetteAmount = (adjustments.vignette ?? 0) / 10; const renderVignette = (x: number, y: number, w: number, h: number) => { if (vignetteAmount <= 0 || w <= 0 || h <= 0) return null; // takes a RuntimeEffect, not a gradient SkShader (passing the latter // throws "Object is not a HostObject"). RadialGradient is the declarative form. return ( ); }; const renderGPSWatermark = () => { if (!gpsWmVisible || !gpsFont || !gpsIconFont) 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())}`; // TOP-LEFT anchored block at the dragged fraction of the same area the // custom mark uses (frame card / photo window / screen). A framed export's // canvas IS that frame, so the fractions carry straight to the burn-in - // exportEngine step 8 draws with the identical formula. Each enabled line // (place name / shot time) stacks under the previous one. const lines: { icon: string; text: string }[] = []; if (gpsWm?.showName !== false) lines.push({ icon: '📍', text: locationName }); if (gpsWm?.showTime !== false) lines.push({ icon: '📷', text: timestampStr }); if (!lines.length) return null; const { ax, ay, iconSize } = gpsAnchor(); const textColor = gpsWm?.color ?? '#f59e0b'; // 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); // The old fixed row1/row2 spacing, generalised to N rows. const rowY = (i: number) => ay + Math.round(iconSize * (1.1 + i * 1.2)); return ( {lines.map((line, i) => ( {/* Icons white so they read as glyphs, not part of the coloured 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. */} )} {renderVignette( framedWin ? framedWin.x : region.x, framedWin ? framedWin.y : region.y, framedWin ? framedWin.w : region.w, framedWin ? framedWin.h : region.h )} {renderFrameOverlay()} {renderGPSWatermark()} {renderCustomWatermark()} {/* 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 ( {/* FRAME-tab view zoom: the feed and its gesture layer magnify together (preview only — a capture is untouched) so the watermark can be dragged against a big picture. The overlays mirror the same matrix. */} {/* 0.5x: a native Camera2 session on the hidden ultra-wide lens, drawn over the (stopped) SkiaCamera feed. The main session has to stay mounted but inactive — the two lenses are mutually exclusive, so turning one on means turning the other off, never both at once. */} {/* Gesture layer: tap = AE/AF lock; vertical drag on the image adjusts the open panel parameter (or EV while AE is locked). Sits below the quick-EV strip, which keeps its own narrower direct-drag handle. */} true} onResponderGrant={onImageDragGrant} onResponderMove={onImageDragMove} onResponderRelease={onImageDragEnd} onResponderTerminate={onImageDragEnd} /> {focusPoint && ( /* The locked-AE/AF reticle IS the unlock control: the old "AE/AF LOCKED — TAP TO UNLOCK" banner was removed from the preview (it sat under the zoom-preset row), so tapping the lock reticle releases the lock. */ )} {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()} {/* Native zoom presets — snap to a controller zoom ratio. 1x is the main lens; 0.5x appears only on a device whose CameraX zoom range reaches below 1 (ultra-wide), and 2x-10x are digital crop. Row sits under the TopBar, clear of the AE-lock chip (top-right) and the EV strip (right edge). uwCover keeps the row mounted across the 0.5x handoff: the 1x session has not restarted yet, so `controller` is still undefined and the row used to blink. */} {(controller || ultraWideOn || uwCover) && ( {zoomPresets.map((z) => ( applyZoom(z)} className={'px-3.5 py-1.5 rounded-full ' + (zoomRatio === z ? 'bg-amber-500' : '')} activeOpacity={0.7} > {z}x ))} )} ); } // Library mode — fullscreen, full-photo (contain), pinch/double-tap zoom. const toneOn = toneParams.some((v) => v !== 0); // One object for every below; keys must match TONE_SKSL's uniforms. const toneUniforms = { dr: toneParams[0], hl: toneParams[1], sh: toneParams[2], vib: toneParams[3], shTr: toneParams[4], shTg: toneParams[5], shTb: toneParams[6], hlTr: toneParams[7], hlTg: toneParams[8], hlTb: toneParams[9], cc: toneParams[10], ccb: toneParams[11], }; const cinemaOn = cinemaEffect != null && !!cinemaParams; const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn); // ---- CLARITY + HDF EFFECT on the LIBRARY preview ----------------------- // The camera worklet and exportEngine both have these two passes; the three // library branches below draw the photo declaratively and had neither, so a // photo opened from the library ignored both knobs. Same maths as the camera: // `sx`/`sy` turn one ORIGINAL image pixel into canvas units (the library // preview is laid out in dp, the camera surface is in sensor px), keeping the // unsharp radius and the bloom sigma identical to the file. const libClarity = adjustments.clarity ?? 0; const libGlow = (adjustments.hdf ?? 0) / 10; const libShaderPath = useShaderPass || (libClarity > 0 && clarityEffect != null); const libPhoto = ( r: { x: number; y: number; w: number; h: number }, fit: 'cover' | 'fill' | 'contain' ) => { const iw = skiaImage ? skiaImage.width() : r.w; const ih = skiaImage ? skiaImage.height() : r.h; // cover/contain scale uniformly (max/min), fill stretches each axis. const sw = r.w / iw; const sh = r.h / ih; const uniform = fit === 'cover' ? Math.max(sw, sh) : fit === 'contain' ? Math.min(sw, sh) : null; const sx = uniform ?? sw; const sy = uniform ?? sh; const rectNode = rect(r.x, r.y, r.w, r.h); let graded: React.ReactNode = ; if (toneEffect != null && toneOn) { graded = {graded}; } if (cinemaOn) { graded = {graded}; } // The Rect must cover exactly the photo area, never the full canvas: a // fullscreen ImageShader with fit="contain" clamps the photo edges across // the black letterbox (reads as "the background is a zoomed copy"). let photo: React.ReactNode = libShaderPath ? ( {libClarity > 0 && clarityEffect != null ? ( {graded} ) : ( graded )} ) : ( ); // Mist (negative clarity) = export pass 4's blur; sigma in original px. // Sharpening < 0 rides the same wrap: the export runs a second Gaussian // after the mist, and two Gaussians in series are one of sqrt(a^2 + b^2), // so a single pass with that sigma matches the file. const libMist = libClarity < 0 ? Math.abs(libClarity / 10) * 4 * sx : 0; const libSoften = (Math.max(0, -(adjustments.sharpening ?? 0)) / 10) * 3 * sx; const libBlur = Math.sqrt(libMist * libMist + libSoften * libSoften); if (libBlur > 0) { photo = ( {photo} ); } // Export clips the bloom and the mist to the canvas, which IS the photo. // The preview must clip too: the HDF glow's blur (and negative clarity's) // otherwise bleeds past the photo onto the black letterbox and over the // frame's mat. Clip to the drawn image, not to the rect: on "contain" the // rect is wider than the picture. const cw = Math.min(r.w, iw * sx); const ch = Math.min(r.h, ih * sy); const photoClip = rect(r.x + (r.w - cw) / 2, r.y + (r.h - ch) / 2, cw, ch); return ( {photo} {/* HDF bloom, the export pass 5b twin: bright pass on the luminance, blur, Screen back at 0.15..0.5 x strength. Radius is a fraction of the drawn width, so any image size blooms like the camera does. */} {libGlow > 0 && glowEffect != null ? ( {graded} ) : null} ); }; // RETRO INSTANT (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 ( {/* Photo is clipped to the window and repositioned by pinch/drag; the card, seam, grain and watermarks stay put. */} {libPhoto({ x: win.x, y: win.y, w: win.w, h: win.h }, polaroidFit)} {grainOpacity > 0 && noiseEffect && ( )} {renderVignette(win.x, win.y, win.w, win.h)} {/* Thin seam between image window and card */} {renderGPSWatermark()} {renderCustomWatermark()} true} onMoveShouldSetResponder={() => true} onResponderGrant={onLibTouchStart} onResponderMove={onLibTouchMove} onResponderRelease={onLibTouchEnd} onResponderTerminate={onLibTouchEnd} /> ); } // 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 ( {/* Photo is clipped to the mat window and repositioned by pinch/ drag; the artwork, grain and watermarks stay put. */} {libPhoto({ x: win.x, y: win.y, w: win.w, h: win.h }, 'cover')} {grainOpacity > 0 && noiseEffect && ( )} {renderVignette(win.x, win.y, win.w, win.h)} {/* 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()} {renderCustomWatermark()} true} onMoveShouldSetResponder={() => true} onResponderGrant={onLibTouchStart} onResponderMove={onLibTouchMove} onResponderRelease={onLibTouchEnd} onResponderTerminate={onLibTouchEnd} /> ); } return ( {libraryImageUri && skiaImage ? ( <> {dispRect ? ( // Tone/Cinema chain, CLARITY and HDF bloom all live in // libPhoto — see its comment for the rect caveat. libPhoto({ x: dispRect.dx, y: dispRect.dy, w: dispRect.dw, h: dispRect.dh }, dispFit) ) : ( )} {grainOpacity > 0 && noiseEffect && ( {/* Grain covers the whole photo area — the contain rect of the library photo, the ratio band on the camera. */} )} {/* Vignette follows the PHOTO, not the screen: in the plain library viewer the image is contain-fitted with letterbox, so a screen-sized gradient would only reveal its clear middle. */} {renderVignette(frameRect.x, frameRect.y, frameRect.w, frameRect.h)} {/* Frame + GPS watermark belong to the photo → zoom with it. Grain is a fullscreen screen effect → stays fixed above. */} {renderFrameOverlay()} {renderGPSWatermark()} {renderCustomWatermark()} {/* Crop keep-rectangle: dim everything the export will drop and outline what it keeps in amber. Drawn last so it masks every overlay (frame, marks, grain) outside the crop. */} {cropScreenPx && ( {/* Committed crop: the surround is opaque (it hides the photo the transform pushed outside the ratio) and the amber outline is gone — nothing left to frame. */} {!libCropView && ( )} )} true} onMoveShouldSetResponder={() => true} onResponderGrant={onLibTouchStart} onResponderMove={onLibTouchMove} onResponderRelease={onLibTouchEnd} onResponderTerminate={onLibTouchEnd} /> ) : ( {libraryImageUri ? 'LOADING IMAGE...' : 'NO IMAGE SELECTED'} Tap the gallery icon below to load a photo )} ); }); export default Viewfinder;