From 93f48537f8496482b046044310e39b72ee62eded Mon Sep 17 00:00:00 2001 From: locphamtran Date: Thu, 10 Sep 2026 20:12:03 +0700 Subject: [PATCH] feat(frame): CROP ratios in the library editor, and the framed-photo fixes CROP chip on the FRAME tab for the library's plain frames: NONE, FREE (drag the box) and the fixed ratios 1:1, 2:3, 3:2, 3:4, 4:3, 16:9. The preview draws the keep-rectangle over the photo and the export honours it pixel-for-pixel, pinching and dragging inside the band to pick the framing. - Viewfinder: build frameRect from the normalised rect so the Skia canvas never sees undefined width/height (nothing to commit until now), keep the watermark drag target across the WATERMARK tab so a zoomed photo no longer resets, and derive the crop band + export rect from the photo fit rect. - exportEngine: crop by fraction rect (or the ratio fallback) before the frame is composited. - Leaving a plain frame clears the crop, and the ratio strip closes with the CROP chip it belongs to. --- App.tsx | 53 +++++- src/components/AdjustmentPanel.tsx | 36 +++- src/components/Viewfinder.tsx | 277 +++++++++++++++++++++++++---- src/types/index.ts | 24 +++ src/utils/exportEngine.ts | 29 ++- 5 files changed, 378 insertions(+), 41 deletions(-) diff --git a/App.tsx b/App.tsx index dfffbd7..d9b3a5a 100644 --- a/App.tsx +++ b/App.tsx @@ -16,7 +16,7 @@ import ToolRail, { TabId } from './src/components/ToolRail'; import SettingsModal from './src/components/SettingsModal'; import PhotoViewerModal from './src/components/PhotoViewerModal'; -import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, StartupMode } from './src/types'; +import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } from './src/types'; import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils'; import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from './src/utils/defaultRecipes'; import { getCurrentGPS, requestLocationPermissions, reverseGeocode } from './src/utils/locationUtils'; @@ -137,6 +137,11 @@ export default function App() { }; // Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar. const [aspectRatio, setAspectRatio] = useState('full'); + // FRAME-tab CROP (library plain frames). 'free' keeps an explicit rectangle + // the Viewfinder lets the user drag; fixed ratios center-crop. Both are + // handed to the export so the file matches the preview. + const [cropRatio, setCropRatio] = useState('none'); + const [cropRect, setCropRect] = useState(DEFAULT_CROP_RECT); const [rawEnabled, setRawEnabled] = useState(false); // Full-frame metering mode (FOCUS → METERING MODE): highlight-weighted meters // the BRIGHTEST area to protect its detail (never blow whites) and pushes the @@ -278,6 +283,8 @@ export default function App() { selectedFrame, useGeotag, aspectRatio, + cropRatio, + cropRect, meterMode, shutterSound, selectedRecipeId: selectedRecipe?.id || null, @@ -293,7 +300,7 @@ export default function App() { } }); return () => subscription.remove(); - }, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, customWm, gpsWm, aspectRatio, meterMode, shutterSound]); + }, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, customWm, gpsWm, aspectRatio, cropRatio, cropRect, meterMode, shutterSound]); // Guards the camera-settings persist effect below: until initSession has // read the stored values, changes are just the initial defaults — writing @@ -379,6 +386,8 @@ export default function App() { if (session.selectedFrame) setSelectedFrame(session.selectedFrame); if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag); if (session.aspectRatio === 'full' || session.aspectRatio === '4:3' || session.aspectRatio === '3:2') setAspectRatio(session.aspectRatio); + if (session.cropRatio) setCropRatio(session.cropRatio); + if (session.cropRect && typeof session.cropRect === 'object') setCropRect({ ...DEFAULT_CROP_RECT, ...session.cropRect }); // NOTE: meterMode/shutterSound/rawEnabled are NOT restored from the // session snapshot here — the session file is only written on background, // so it can be stale after a force-kill. CAMERA_SETTINGS_KEY above @@ -509,6 +518,13 @@ export default function App() { const handleUpdateFrame = (frameId: FrameId) => { setSelectedFrame(frameId); + // CROP is a plain-frame tool (polaroid/wall crop through their own window). + // Keep the crop out of a frame that ignores it, or the ratio survives in the + // strip and lights the CROP chip on a frame the export never crops. + if (frameId !== 'none' && frameId !== 'classic-white' && frameId !== 'cinematic') { + setCropRatio('none'); + setCropRect(DEFAULT_CROP_RECT); + } if (selectedRecipe) { setSelectedRecipe({ ...selectedRecipe, @@ -866,8 +882,26 @@ export default function App() { const libraryPlainFrame = selectedFrame === 'none' || selectedFrame === 'classic-white' || selectedFrame === 'cinematic'; const libraryAspect = aspectRatio === 'full' || libraryPlainFrame ? undefined : aspectRatio; + // FRAME-tab CROP: only the plain frames (polaroid/wall crop through their + // own window). The native exporter knows nothing about it, so a cropped + // export always takes the Skia path. + // The fixed ratios take their rectangle from the viewfinder: the band plus + // whatever pinch/drag the user applied inside it. cropWH stays as the + // fallback for the (impossible in practice) case of an unloaded photo. + const ratioCrop = + cropRatio !== 'none' && cropRatio !== 'free' + ? viewfinderRef.current?.getCropRatioRect?.() ?? null + : null; + const cropOptions: { cropWH?: number; cropRect?: CropRect } = + !libraryPlainFrame || cropRatio === 'none' + ? {} + : cropRatio === 'free' + ? { cropRect } + : ratioCrop + ? { cropRect: ratioCrop } + : { cropWH: CROP_W_H[cropRatio] }; - const result = NATIVE_EXPORT + const result = NATIVE_EXPORT && !cropOptions.cropRect ? await processAndExportPhotoNative( libraryImageUri, { @@ -908,6 +942,8 @@ export default function App() { watermarkRotation: wmRotation, gpsWatermark: gpsWm, wallframeLandscape: wallLandscape, + // FRAME-tab CROP: the exact keep-rectangle the preview showed. + ...cropOptions, // Pinch/drag inside a polaroid card window or wall-frame opening: // save the same crop the preview showed (null = centered 1x). frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null, @@ -1095,6 +1131,9 @@ export default function App() { rawOutput={rawPhotoOutput} rawEnabled={rawEnabled} aspectRatio={aspectRatio} + cropRatio={cropRatio} + cropRect={cropRect} + onCropRectChange={setCropRect} onSessionError={handleCameraSessionError} onExposureChange={(ev) => handleUpdateAdjustments({ exposureCompensation: ev })} meteringEvOffset={meteringActive ? METER_HL_EV : 0} @@ -1144,6 +1183,14 @@ export default function App() { currentRecipeId={selectedRecipe?.id || ''} adjustments={adjustments} selectedFrame={selectedFrame} + cropRatio={cropRatio} + onUpdateCropRatio={setCropRatio} + cropEnabled={ + mode === 'library' && + (selectedFrame === 'none' || + selectedFrame === 'classic-white' || + selectedFrame === 'cinematic') + } photoRotation={photoRotation} onToggleRotation={() => setPhotoRotation((r) => (r === 0 ? 90 : 0))} wallLandscape={wallLandscape} diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index 6a4cc1c..64c2b9d 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -4,7 +4,7 @@ import Slider from '@react-native-community/slider'; import * as Haptics from 'expo-haptics'; import { Trash2 } from 'lucide-react-native'; import { Skia } from '@shopify/react-native-skia'; -import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter } from '../types'; +import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter, CropRatio } from '../types'; import { FRAMES } from '../utils/frameUtils'; import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs'; import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes'; @@ -28,7 +28,8 @@ type GroupKey = | 'wmFont' | 'gpsColor' | 'gpsName' - | 'gpsTime'; + | 'gpsTime' + | 'crop'; interface AdjustmentPanelProps { activeTab: TabId; @@ -36,6 +37,12 @@ interface AdjustmentPanelProps { currentRecipeId: string; adjustments: ColorAdjustments; selectedFrame: FrameId; + // FRAME tab CROP. Only meaningful on the library's plain frames (the camera + // composes through its ratio band, polaroid/wall through their own window), + // so App hides the chip everywhere else. + cropRatio: CropRatio; + onUpdateCropRatio: (ratio: CropRatio) => void; + cropEnabled?: boolean; useGeotag: boolean; // RAW (DNG sidecar) / aspect-ratio / metering-mode now live in the gear // Settings sheet (gear between library picker and shutter) — no longer @@ -150,6 +157,9 @@ export default function AdjustmentPanel({ currentRecipeId, adjustments, selectedFrame, + cropRatio, + onUpdateCropRatio, + cropEnabled, useGeotag, openParam, onOpenParamChange, @@ -203,6 +213,11 @@ export default function AdjustmentPanel({ setShowRecipes(false); setCreateVisible(false); }, [activeTab]); + // CROP is a plain-frame tool: the moment the selected frame drops it the chip + // unmounts, so the ratio strip must close with it (no orphan CROP row). + useEffect(() => { + if (!cropEnabled) setOpenGroup((g) => (g === 'crop' ? null : g)); + }, [cropEnabled]); const toggleParam = (key: string) => { haptic(); @@ -416,6 +431,22 @@ export default function AdjustmentPanel({ options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }], onPick: (v) => onUpdateGpsWm({ ...gpsWm, showName: v === 'on' }), }, + crop: { + label: 'CROP', off: 'none', value: cropRatio, + // 'free' keeps an explicit user-dragged rectangle; the rest center-crop + // the photo to the fixed shape the export writes. + options: [ + { v: 'none', d: 'NONE' }, + { v: 'free', d: 'FREE' }, + { v: '1:1', d: '1:1' }, + { v: '2:3', d: '2:3' }, + { v: '3:2', d: '3:2' }, + { v: '3:4', d: '3:4' }, + { v: '4:3', d: '4:3' }, + { v: '16:9', d: '16:9' }, + ], + onPick: (v) => onUpdateCropRatio(v as CropRatio), + }, gpsTime: { label: 'TIME', off: 'ON', value: gpsWm.showTime === false ? 'OFF' : 'ON', @@ -542,6 +573,7 @@ export default function AdjustmentPanel({ selectedFrame, (val) => onUpdateFrame(val as FrameId) )} + {cropEnabled && renderChip(groupChip('crop'))} {renderChip({ key: 'rotate', label: photoRotation === 0 ? 'ROTATE 90' : 'ROTATE 0', diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 6dd4cb0..59fdbd1 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -27,7 +27,7 @@ import { FontStyle, } from '@shopify/react-native-skia'; import { Lock } from 'lucide-react-native'; -import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments, ASPECT_RATIO_W_H } from '../types'; +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 } from '../utils/toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader'; @@ -60,6 +60,14 @@ interface ViewfinderProps { // 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; + 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. @@ -143,6 +151,13 @@ export interface ViewfinderHandle { * exactly the crop the user framed after pinch/drag. */ getFrameWindowZoom(): { s: number; u: number; v: number } | null; + /** + * 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; } const noiseEffect = Skia.RuntimeEffect.Make(` @@ -197,6 +212,9 @@ const Viewfinder = forwardRef(function Viewfi onRequestCameraPermission, photoOutput, aspectRatio, + cropRatio = 'none', + cropRect = DEFAULT_CROP_RECT, + onCropRectChange, rawEnabled, rawOutput, onSessionError, @@ -255,6 +273,13 @@ const Viewfinder = forwardRef(function Viewfi 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'; // 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. @@ -545,6 +570,9 @@ const Viewfinder = forwardRef(function Viewfi getFrameWindowZoom() { return frameZoomRef.current; }, + getCropRatioRect() { + return cropRatioRectRef.current; + }, }), [ref] ); @@ -975,14 +1003,15 @@ const Viewfinder = forwardRef(function Viewfi // 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 }); - // The mark editor works in the UN-zoomed composition: a photo zoom left over - // from the panel-closed pass (zoom in to check a slider, then come back to - // WATERMARK) shifted the mark's hit box with the matrix AND tripped the - // `libZoom.s <= 1.01` guards, so the mark could neither be dragged nor - // pinched. Opening the editor therefore drops the photo zoom. + // 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 (wmEditing) setLibZoom((z) => (z.s <= 1.001 ? z : { s: 1, tx: 0, ty: 0 })); - }, [wmEditing]); + if (libCropActive) setLibZoom({ s: 1, tx: 0, ty: 0 }); + }, [libCropActive, cropRatio]); const pinchRef = useRef<{ d: number; s: number; tx: number; ty: number; fx: number; fy: number } | null>(null); const pinchUsedRef = useRef(false); @@ -1005,6 +1034,28 @@ const Viewfinder = forwardRef(function Viewfi 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]); // 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 @@ -1015,7 +1066,31 @@ const Viewfinder = forwardRef(function Viewfi 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. - const frameRect = mode === 'library' && dispRect ? dispRect : region; + // 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; + 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 @@ -1059,6 +1134,9 @@ const Viewfinder = forwardRef(function Viewfi // whole composition region is the photo) and for the live camera. const zoomWindowRect = (): { x: number; y: number; w: number; h: number } | null => { if (mode !== 'library') return 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; @@ -1089,18 +1167,26 @@ const Viewfinder = forwardRef(function Viewfi }); }); - // Clamp pan/zoom so the photo always keeps COVERING the framed window (never - // drags far enough to reveal the card/mat through the transparent opening). - // Transform is P' = s*P + t in canvas coords; with the photo drawn over the - // window rect, covering means t in [(x+w)(1-s), x(1-s)] (y likewise). + // 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 - if (s <= 1.0001) return { s, tx: 0, ty: 0 }; + 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: Math.min(win.x * (1 - s), Math.max((win.x + win.w) * (1 - s), tx)), - ty: Math.min(win.y * (1 - s), Math.max((win.y + win.h) * (1 - s), ty)), + 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 @@ -1352,14 +1438,81 @@ const Viewfinder = forwardRef(function Viewfi // 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; panRef.current = ts.length === 1 ? { x: ts[0].locationX, y: ts[0].locationY, tx: libZoom.tx, ty: libZoom.ty } : null; panUsedRef.current = false; - if (ts.length === 1 && (wmArmed || gpsWmArmed) && libZoom.s <= 1.01) { - wmDragTgtRef.current = wmTargetAt(ts[0].locationX, ts[0].locationY); - } + if (ts.length === 1 && (wmArmed || gpsWmArmed)) { + const x = ts[0].locationX; + const y = ts[0].locationY; + const boxHit = (wmArmed && wmBoxHit(wmBox(), x, y)) || (gpsWmArmed && gpsBoxHit(x, y)); + // Zoomed photo: only a touch ON a mark takes the gesture, the rest keeps + // panning the framed photo. At 1x the lone armed mark takes any touch so + // a plain tap still places it. + wmDragTgtRef.current = libZoom.s <= 1.01 || boxHit ? wmTargetAt(x, y) : null; + } else wmDragTgtRef.current = null; if (ts.length >= 2) { const [a, b] = ts; const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY); @@ -1380,6 +1533,10 @@ const Viewfinder = forwardRef(function Viewfi } }; const onLibTouchMove = (e: any) => { + if (libFreeCrop) { + moveCropDrag(e); + return; + } const ts = e.nativeEvent.touches; // WATERMARK tab open: two fingers scale the mark under them (or do // nothing) — the photo itself only zooms once the editor is closed. @@ -1412,13 +1569,9 @@ const Viewfinder = forwardRef(function Viewfi } // 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 ((wmArmed || gpsWmArmed) && ts.length === 1 && libZoom.s <= 1.01) { + if (ts.length === 1 && wmDragTgtRef.current) { const t = ts[0]; - placeWm( - t.pageX ?? t.locationX, - t.pageY ?? t.locationY, - wmDragTgtRef.current ?? (wmArmed ? 'custom' : 'gps') - ); + placeWm(t.pageX ?? t.locationX, t.pageY ?? t.locationY, wmDragTgtRef.current); return; } // One finger, no pinch running: drag the photo inside the framed window @@ -1467,8 +1620,14 @@ const Viewfinder = forwardRef(function Viewfi } }; const onLibTouchEnd = (e: any) => { + if (libFreeCrop) { + cropDragRef.current = null; + return; + } const wasPinch = pinchUsedRef.current; const wasPan = panUsedRef.current; + // Decided at touch-down; the reset below would lose it. + const wmTgt = wmDragTgtRef.current; pinchRef.current = null; wmPinchRef.current = null; wmDragTgtRef.current = null; @@ -1499,7 +1658,7 @@ const Viewfinder = forwardRef(function Viewfi } // 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) { + if ((wmArmed || gpsWmArmed) && (libZoom.s <= 1.01 || wmTgt)) { const now = Date.now(); const prev = lastTapRef.current; lastTapRef.current = { x, y, t: now }; @@ -1515,7 +1674,7 @@ const Viewfinder = forwardRef(function Viewfi onWmEdit?.(); return; } - placeWm(x, y, wmTargetAt(x, y) ?? 'custom'); + placeWm(x, y, wmTgt ?? wmTargetAt(x, y) ?? 'custom'); commitWm(); return; } @@ -1571,6 +1730,9 @@ const Viewfinder = forwardRef(function Viewfi 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; @@ -1581,6 +1743,25 @@ const Viewfinder = forwardRef(function Viewfi }; })(); + // 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; @@ -2197,9 +2378,7 @@ const Viewfinder = forwardRef(function Viewfi {/* 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. */} - {dispRect - ? renderVignette(dispRect.dx, dispRect.dy, dispRect.dw, dispRect.dh) - : renderVignette(region.x, region.y, region.w, region.h)} + {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. */} @@ -2207,6 +2386,44 @@ const Viewfinder = forwardRef(function Viewfi {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. */} + {cropWinPx && ( + + + + + + + + )} , number> = { '3:2': 2 / 3, }; +// FRAME-tab CROP. Ratio convention is w:h (so 2:3 is an upright crop, 16:9 a +// wide one); 'free' is a user-dragged rectangle instead of a fixed shape. +export type CropRatio = 'none' | 'free' | '1:1' | '2:3' | '3:2' | '3:4' | '4:3' | '16:9'; +// Keep-rectangle as 0..1 fractions of the (post-rotation) source photo. +export interface CropRect { + x: number; + y: number; + w: number; + h: number; +} +// w/h of the kept area for each fixed crop choice. +export const CROP_W_H: Record, number> = { + '1:1': 1, + '2:3': 2 / 3, + '3:2': 3 / 2, + '3:4': 3 / 4, + '4:3': 4 / 3, + '16:9': 16 / 9, +}; +// FREE crop starts as a visible inset rectangle the user can immediately grab. +export const DEFAULT_CROP_RECT: CropRect = { x: 0.1, y: 0.1, w: 0.8, h: 0.8 }; +// Smallest FREE-crop side, in source fractions. +export const MIN_CROP_FRAC = 0.08; + // Cinematic seasonal atmosphere grade. Not a fake re-light: it reads the // photo's EXISTING exposure — warm tint + soft glow on areas already lit, // cool tint in shade, plus a seasonal atmospheric haze/fog veil. diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index a514282..a03e75f 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -3,7 +3,7 @@ import { File } from 'expo-file-system'; import * as FileSystem from 'expo-file-system/legacy'; import * as MediaLibrary from 'expo-media-library/legacy'; import { Asset } from 'expo-asset'; -import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H } from '../types'; +import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H, CropRect } from '../types'; import { getSkiaColorMatrix, applyExposureGain } from './colorUtils'; import { TONE_SKSL, getToneUniforms, toneIsActive } from './toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader'; @@ -35,6 +35,13 @@ export interface ExportOptions { // viewfinder displayed. WALL FRAME ignores this (frame keeps its fixed // opening and export size). 'full'/undefined = no crop. aspect?: AspectRatio; + // FRAME-tab CROP, fixed ratio: width/height of the kept area, center-cropped + // exactly like `aspect` but for every non-full ratio. Wins over `aspect`. + cropWH?: number; + // FRAME-tab CROP, FREE: the kept rectangle as 0..1 fractions of the + // (post-rotation) source image. Wins over `cropWH` and `aspect`. WALL FRAME + // ignores both (fixed frame opening, cover-cropped by the artwork itself). + cropRect?: CropRect | null; // Custom text watermark: typed text + 0..1 fractions of the photo area // (BASELINE anchor + 0.35em drop — same formula as the viewfinder overlay, // so the export lands where the preview showed it). Amber mono, no icons. @@ -139,18 +146,28 @@ export async function processAndExportPhoto( // requested ratio. Runs BEFORE any processing so frame/GPS/grain/sharpen // all apply to exactly the pixels the user framed. Wall Frame is exempt — // its export is the fixed frame artwork whose opening cover-crops anyway. - const ratioWH = options?.aspect && options.aspect !== 'full' ? ASPECT_RATIO_W_H[options.aspect] : null; - if (ratioWH && frameId !== 'wallframe') { + const aspectWH = options?.aspect && options.aspect !== 'full' ? ASPECT_RATIO_W_H[options.aspect] : null; + // FRAME-tab CROP (the App only passes it for the plain frames): an explicit + // FREE rectangle wins, then a fixed crop ratio, then the camera composition + // ratio. + const cropRect = options?.cropRect ?? null; + const ratioWH = cropRect ? null : options?.cropWH ?? aspectWH; + if ((cropRect || ratioWH) && frameId !== 'wallframe') { const curAspect = width / height; let cropW = width; let cropH = height; let srcX = 0; let srcY = 0; - if (curAspect > ratioWH) { - cropW = Math.round(height * ratioWH); + if (cropRect) { + srcX = Math.max(0, Math.min(width - 1, Math.round(cropRect.x * width))); + srcY = Math.max(0, Math.min(height - 1, Math.round(cropRect.y * height))); + cropW = Math.max(1, Math.min(width - srcX, Math.round(cropRect.w * width))); + cropH = Math.max(1, Math.min(height - srcY, Math.round(cropRect.h * height))); + } else if (curAspect > (ratioWH as number)) { + cropW = Math.round(height * (ratioWH as number)); srcX = Math.round((width - cropW) / 2); } else { - cropH = Math.round(width / ratioWH); + cropH = Math.round(width / (ratioWH as number)); srcY = Math.round((height - cropH) / 2); } const cropSurface = createSurface(cropW, cropH);