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:
2026-09-10 20:12:03 +07:00
parent 299d81bade
commit 93f48537f8
5 changed files with 378 additions and 41 deletions
+34 -2
View File
@@ -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
View File
@@ -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"