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.
This commit is contained in:
@@ -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<AspectRatio>('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<CropRatio>('none');
|
||||
const [cropRect, setCropRect] = useState<CropRect>(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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
+247
-30
@@ -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<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
onRequestCameraPermission,
|
||||
photoOutput,
|
||||
aspectRatio,
|
||||
cropRatio = 'none',
|
||||
cropRect = DEFAULT_CROP_RECT,
|
||||
onCropRectChange,
|
||||
rawEnabled,
|
||||
rawOutput,
|
||||
onSessionError,
|
||||
@@ -255,6 +273,13 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
getFrameWindowZoom() {
|
||||
return frameZoomRef.current;
|
||||
},
|
||||
getCropRatioRect() {
|
||||
return cropRatioRectRef.current;
|
||||
},
|
||||
}),
|
||||
[ref]
|
||||
);
|
||||
@@ -975,14 +1003,15 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<CropRect | null>(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<ViewfinderHandle, ViewfinderProps>(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. */}
|
||||
<Group matrix={libZoomMatrix}>
|
||||
@@ -2207,6 +2386,44 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
{renderGPSWatermark()}
|
||||
{renderCustomWatermark()}
|
||||
</Group>
|
||||
{/* 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 && (
|
||||
<Group>
|
||||
<Rect x={0} y={0} width={vw} height={Math.max(0, cropWinPx.y)} color="#000000" opacity={0.55} />
|
||||
<Rect
|
||||
x={0}
|
||||
y={cropWinPx.y + cropWinPx.h}
|
||||
width={vw}
|
||||
height={Math.max(0, vh - cropWinPx.y - cropWinPx.h)}
|
||||
color="#000000" opacity={0.55}
|
||||
/>
|
||||
<Rect
|
||||
x={0}
|
||||
y={cropWinPx.y}
|
||||
width={Math.max(0, cropWinPx.x)}
|
||||
height={cropWinPx.h}
|
||||
color="#000000" opacity={0.55}
|
||||
/>
|
||||
<Rect
|
||||
x={cropWinPx.x + cropWinPx.w}
|
||||
y={cropWinPx.y}
|
||||
width={Math.max(0, vw - cropWinPx.x - cropWinPx.w)}
|
||||
height={cropWinPx.h}
|
||||
color="#000000" opacity={0.55}
|
||||
/>
|
||||
<Rect
|
||||
x={cropWinPx.x}
|
||||
y={cropWinPx.y}
|
||||
width={cropWinPx.w}
|
||||
height={cropWinPx.h}
|
||||
color="#f59e0b"
|
||||
style="stroke"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Canvas>
|
||||
<View
|
||||
className="absolute inset-0"
|
||||
|
||||
@@ -30,6 +30,30 @@ export const ASPECT_RATIO_W_H: Record<Exclude<AspectRatio, 'full'>, 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<Exclude<CropRatio, 'none' | 'free'>, 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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user