Move the CROP band, not the picture
Library CROP now resizes the band in photo fractions, the way FREE always did, instead of panning a re-scaled photo under a screen-sized rectangle: what the band encloses is the export, with no zoom transform left to undo and no aspect that only holds on a square photo. The band is laid out below the top strip, whose height TopBar now reports, so it can never peek under the header. The grain hashes a cell of width/1080 instead of the raw pixel grid, so a 4000px export is no longer ~4x finer than the preview that tuned it.
This commit is contained in:
@@ -87,6 +87,9 @@ export default function App() {
|
||||
// photo. Library mode letterboxes the image above it so no watermark
|
||||
// position hides under an open panel. Null until the first layout pass.
|
||||
const [deckTop, setDeckTop] = useState<number | null>(null);
|
||||
// Bottom edge of the floating top strip -> the library photo starts there; a
|
||||
// ratio band peeking under the header is a band the user cannot see.
|
||||
const [topInset, setTopInset] = useState(0);
|
||||
// Library photo rotation (FRAME tab): the quarter turn in degrees clockwise
|
||||
// plus the fine STRAIGHTEN offset. Both feed the preview source and the
|
||||
// export alike, the straighten angle applied on top of the turn.
|
||||
@@ -1726,6 +1729,7 @@ export default function App() {
|
||||
gpsInfo={gpsInfo}
|
||||
libraryImageUri={libraryImageUri}
|
||||
availableHeight={deckTop}
|
||||
topInset={topInset}
|
||||
photoRotation={shown.photoRotation}
|
||||
photoStraighten={shown.photoStraighten}
|
||||
cameraPermissionGranted={cameraPermissionGranted}
|
||||
@@ -1778,6 +1782,7 @@ export default function App() {
|
||||
onExportShare={handleExportShare}
|
||||
onUndo={handleUndo}
|
||||
undoDepth={undoDepth}
|
||||
onHeight={setTopInset}
|
||||
/>
|
||||
|
||||
{/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */}
|
||||
|
||||
@@ -21,6 +21,9 @@ interface TopBarProps {
|
||||
// drawn: the icon turns amber while there is a step to go back to.
|
||||
onUndo: () => void;
|
||||
undoDepth: number;
|
||||
// Height of the floating top strip (its own onLayout): App keeps the library
|
||||
// photo — and with it the ratio band — out from under it.
|
||||
onHeight?: (height: number) => void;
|
||||
}
|
||||
|
||||
export default function TopBar({
|
||||
@@ -32,6 +35,7 @@ export default function TopBar({
|
||||
onExportShare,
|
||||
onUndo,
|
||||
undoDepth,
|
||||
onHeight,
|
||||
}: TopBarProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@@ -74,6 +78,7 @@ export default function TopBar({
|
||||
<>
|
||||
<View
|
||||
style={{ paddingTop: insets.top + 6 }}
|
||||
onLayout={(e) => onHeight?.(e.nativeEvent.layout.height)}
|
||||
className="absolute top-0 left-0 right-0 z-20 flex-row items-start justify-between px-4"
|
||||
>
|
||||
<View className="max-w-[62%] rounded-xl border border-zinc-800/80 bg-black/45 px-3 py-2">
|
||||
|
||||
+213
-80
@@ -52,6 +52,10 @@ interface ViewfinderProps {
|
||||
// custom-watermark position) hides under an open panel. Camera ignores this:
|
||||
// its feed rects are normalized against the real window.
|
||||
availableHeight?: number | null;
|
||||
// Library only: bottom edge of the floating top strip. The photo and the
|
||||
// ratio band are laid out BELOW it — a keep-rectangle whose top edge hides
|
||||
// under the header is one the user cannot aim.
|
||||
topInset?: number | null;
|
||||
// Library only: user rotation of the inserted photo — the quarter turn in
|
||||
// degrees clockwise (0/90/180/270) plus the fine STRAIGHTEN offset, applied
|
||||
// on top of that turn.
|
||||
@@ -185,10 +189,29 @@ export interface ViewfinderHandle {
|
||||
detectHorizon(): number | null;
|
||||
}
|
||||
|
||||
// Grain: value noise on a CELL of `u` canvas px rather than on the raw pixel
|
||||
// grid. A per-pixel hash gave the preview (a ~1080px rect) and the export (a
|
||||
// 4000px image) two different grains — the file's came out ~4x finer than
|
||||
// what was tuned on screen. Callers pass the rect's width / GRAIN_REF so both
|
||||
// hash at the same size relative to the picture; u = 1 (the preview) is exactly
|
||||
// the old per-pixel hash, since the smoothed cell fraction is 0 there. The
|
||||
// bilinear over the four cell corners is what makes a clump instead of a pixel
|
||||
// of static — at cell 1 it collapses back to the plain hash.
|
||||
const noiseEffect = Skia.RuntimeEffect.Make(`
|
||||
uniform float u;
|
||||
float grainHash(vec2 q) {
|
||||
return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
}
|
||||
vec4 main(vec2 pos) {
|
||||
float r = fract(sin(dot(pos.xy, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
return vec4(vec3(r), 1.0);
|
||||
vec2 p = pos.xy / max(u, 1.0);
|
||||
vec2 i = floor(p);
|
||||
vec2 f = p - i;
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
float g00 = grainHash(i);
|
||||
float g10 = grainHash(i + vec2(1.0, 0.0));
|
||||
float g01 = grainHash(i + vec2(0.0, 1.0));
|
||||
float g11 = grainHash(i + vec2(1.0, 1.0));
|
||||
return vec4(vec3(mix(mix(g00, g10, f.x), mix(g01, g11, f.x), f.y)), 1.0);
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -256,6 +279,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
gpsInfo,
|
||||
libraryImageUri,
|
||||
availableHeight,
|
||||
topInset,
|
||||
cameraPermissionGranted,
|
||||
onRequestCameraPermission,
|
||||
photoOutput,
|
||||
@@ -293,6 +317,11 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
// Library: everything below this line is the bottom deck, so the photo is
|
||||
// letterboxed above it (top-anchored: same fractions, same export).
|
||||
const vh = mode === 'library' && availableHeight && availableHeight > 0 ? availableHeight : winH;
|
||||
// The top strip floats over the hero, so the usable picture area starts at
|
||||
// its bottom edge. Clamped to a third of the viewport: a stale or absurd
|
||||
// measurement must not squeeze the photo into a sliver.
|
||||
const vhTop =
|
||||
mode === 'library' ? Math.max(0, Math.min(topInset ?? 0, vh / 3)) : 0;
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Composition ratio band. With a ratio active the photo area is a centered
|
||||
@@ -1430,6 +1459,13 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const grainOpacity =
|
||||
Math.min(10, Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))) / 20;
|
||||
|
||||
// Grain cell size: the noise hashes one cell per `rect.w / GRAIN_REF` canvas
|
||||
// px, so a ~1080px preview rect hashes per pixel and a 4000px export per ~4px
|
||||
// cell — the same grain at the same size relative to the picture (see
|
||||
// noiseEffect). Without this the exported grain was ~4x finer than the grain
|
||||
// the user had just tuned on screen.
|
||||
const GRAIN_REF = 1080;
|
||||
|
||||
// Library-still viewer: pinch-zoom (2 fingers) + double-tap zoom at the tap
|
||||
// point / double-tap again to reset to 100%. Transform is a Skia matrix
|
||||
// P' = scale*P + (tx,ty); gesture math keeps the focal point stationary.
|
||||
@@ -1450,7 +1486,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const [viewZoom, setViewZoom] = useState({ s: 1, tx: 0, ty: 0 });
|
||||
// Magnified preview (FRAME tab only): one finger then drags the picture
|
||||
// inside the viewport, so it is the pan that pairs with the pinch above.
|
||||
const viewZoomed = frameTabActive && viewZoom.s > 1.01;
|
||||
// CROP claims the two fingers for the photo instead — the band must not move.
|
||||
const viewZoomed = frameTabActive && !libCropActive && viewZoom.s > 1.01;
|
||||
// Keep the scaled view covering the viewport: no edge may pull inside it.
|
||||
const clampViewZoom = (z: { s: number; tx: number; ty: number }) => {
|
||||
const s = clampZoom(z.s);
|
||||
@@ -1462,8 +1499,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
};
|
||||
const viewPinchRef = useRef<{ d: number; s: number; tx: number; ty: number; fx: number; fy: number } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!frameTabActive) setViewZoom({ s: 1, tx: 0, ty: 0 });
|
||||
}, [frameTabActive]);
|
||||
if (!frameTabActive || libCropActive) setViewZoom({ s: 1, tx: 0, ty: 0 });
|
||||
}, [frameTabActive, libCropActive]);
|
||||
|
||||
const pinchRef = useRef<{ d: number; s: number; tx: number; ty: number; fx: number; fy: number } | null>(null);
|
||||
const pinchUsedRef = useRef(false);
|
||||
@@ -1481,33 +1518,91 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const w = img.width();
|
||||
const h = img.height();
|
||||
if (!(w > 0 && h > 0)) return null;
|
||||
const s = Math.min(vw / w, vh / h);
|
||||
const s = Math.min(vw / w, (vh - vhTop) / h);
|
||||
const dw = w * s;
|
||||
const dh = h * s;
|
||||
return { dx: (vw - dw) / 2, dy: (vh - dh) / 2, dw, dh };
|
||||
}, [skiaImage, vw, vh]);
|
||||
// Fixed-ratio CROP: the keep-rectangle is the largest centred rect of that
|
||||
// shape inside the photo, and the photo is NOT re-scaled to it — the whole
|
||||
// picture stays on screen (contain) with the band marking what the export
|
||||
// keeps, so the user can pinch/drag the picture under the band to choose the
|
||||
// framing. Sizing the band off the photo (not the viewport) keeps it inside
|
||||
// the picture at 1x, so the band never shows letterbox either.
|
||||
return { dx: (vw - dw) / 2, dy: vhTop + (vh - vhTop - dh) / 2, dw, dh };
|
||||
}, [skiaImage, vw, vh, vhTop]);
|
||||
// CROP gestures — the BAND moves, the picture does not. That is the rule
|
||||
// FREE always used and the only one that keeps the preview honest: the export
|
||||
// is then exactly the band over the picture as drawn, with no transform left
|
||||
// to un-do. One finger drags the band (or an edge/corner of a FREE band,
|
||||
// which resizes it); two fingers scale it about the point under them.
|
||||
// Maths runs in SOURCE fractions (0..1 of the photo), so the exported crop is
|
||||
// the rectangle the user sees whatever the display scale.
|
||||
// A fixed ratio keeps w/h fixed, so the band can never drift off its ratio;
|
||||
// it is also why its edge hit-test is skipped below.
|
||||
const cropRatioWH: number | null =
|
||||
libCropActive && cropRatio !== 'free' ? CROP_W_H[cropRatio] : null;
|
||||
const clampCropRect = (r: CropRect): CropRect => {
|
||||
const cl = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||
if (cropRatioWH != null) {
|
||||
// Fractions are of the PHOTO's width and height, not of a square: a
|
||||
// fraction-space square is only square on a square photo, so the
|
||||
// wanted PIXEL ratio has to be restated as w/h = ratioWH / (dw/dh).
|
||||
// Reading it as w/h = ratioWH drew a 3:4 band on a 3:4 photo and a
|
||||
// square one on anything else — and the export followed the band.
|
||||
const k = imageFitRect && imageFitRect.dh > 0 ? imageFitRect.dw / imageFitRect.dh : 1;
|
||||
const ratio = cropRatioWH / k;
|
||||
const cx = r.x + r.w / 2;
|
||||
const cy = r.y + r.h / 2;
|
||||
let w = cl(r.w, MIN_CROP_FRAC, 1);
|
||||
let h = w / ratio;
|
||||
if (h > 1) {
|
||||
h = 1;
|
||||
w = Math.min(1, h * ratio);
|
||||
} else if (h < MIN_CROP_FRAC) {
|
||||
h = MIN_CROP_FRAC;
|
||||
w = Math.min(1, h * ratio);
|
||||
}
|
||||
return { x: cl(cx - w / 2, 0, 1 - w), y: cl(cy - h / 2, 0, 1 - h), w, h };
|
||||
}
|
||||
const w = cl(r.w, MIN_CROP_FRAC, 1);
|
||||
const h = cl(r.h, MIN_CROP_FRAC, 1);
|
||||
return { x: cl(r.x, 0, 1 - w), y: cl(r.y, 0, 1 - h), w, h };
|
||||
};
|
||||
// Fixed-ratio CROP: the band is a rectangle OF THE PICTURE, held in the same
|
||||
// 0..1 fractions FREE uses, and the user resizes it (a pinch scales it about
|
||||
// the point under the fingers). The picture itself never moves and is never
|
||||
// re-scaled, so what the band encloses IS the export, with no zoom transform
|
||||
// left to un-do, and the ratio reads exactly like FREE does. Its default,
|
||||
// dropped in when the ratio is picked, is still the largest centred rect of
|
||||
// that shape — now measured against the photo rather than the screen, which
|
||||
// (photo aspect == screen aspect) is the identical rectangle.
|
||||
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;
|
||||
}
|
||||
const r = clampCropRect(cropRect);
|
||||
return {
|
||||
dx: imageFitRect.dx + (imageFitRect.dw - dw) / 2,
|
||||
dy: imageFitRect.dy + (imageFitRect.dh - dh) / 2,
|
||||
dw,
|
||||
dh,
|
||||
dx: imageFitRect.dx + r.x * imageFitRect.dw,
|
||||
dy: imageFitRect.dy + r.y * imageFitRect.dh,
|
||||
dw: r.w * imageFitRect.dw,
|
||||
dh: r.h * imageFitRect.dh,
|
||||
};
|
||||
}, [libCropActive, cropRatio, imageFitRect]);
|
||||
}, [libCropActive, cropRatio, imageFitRect, cropRect]);
|
||||
// Picking a ratio drops the band in at the largest centred rectangle of that
|
||||
// shape the PICTURE can hold — the same "fully visible in the frame" default
|
||||
// the old screen-sized band gave (a 3:4 photo on a 3:4 screen lands on the
|
||||
// identical rect), just measured against the photo so the band can never sit
|
||||
// outside it. R is the wanted aspect restated in fraction space: a rect of
|
||||
// fraction w/h covers pixels of aspect (w*dw)/(h*dh), so R = rw / (dw/dh).
|
||||
const cropRatioDefaultRef = useRef<CropRatio | null>(null);
|
||||
useEffect(() => {
|
||||
if (!imageFitRect || !(imageFitRect.dw > 0 && imageFitRect.dh > 0)) return;
|
||||
if (cropRatio === 'none' || cropRatio === 'free' || !onCropRectChange) return;
|
||||
if (cropRatioDefaultRef.current === cropRatio) return;
|
||||
// First look at the loaded picture: a ratio already selected here came from
|
||||
// a restored session, whose stored rectangle is in this same currency —
|
||||
// keep it, and only re-default on the NEXT ratio change.
|
||||
if (cropRatioDefaultRef.current === null) {
|
||||
cropRatioDefaultRef.current = cropRatio;
|
||||
return;
|
||||
}
|
||||
cropRatioDefaultRef.current = cropRatio;
|
||||
const R = CROP_W_H[cropRatio] / (imageFitRect.dw / imageFitRect.dh);
|
||||
const w = R >= 1 ? 1 : R;
|
||||
const h = R >= 1 ? 1 / R : 1;
|
||||
onCropRectChange({ x: (1 - w) / 2, y: (1 - h) / 2, w, h });
|
||||
}, [cropRatio, imageFitRect, onCropRectChange]);
|
||||
// Committed crop geometry: the band's contents scaled up so the ratio fills
|
||||
// the viewer (centred, black around it). Frame, marks, grain and vignette all
|
||||
// hug the band from inside the photo group, so one extra matrix over that
|
||||
@@ -1527,8 +1622,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
}, [libCropApplied, libCropBand, vw, vh]);
|
||||
// Photo display rect + draw fit in the plain library viewer: the full photo
|
||||
// is contain-fitted to the screen (fill into its own aspect rect — no
|
||||
// distortion, identical to the old fullscreen contain). The ratio band only
|
||||
// shapes the CAMERA composition; a library export is the photo as framed.
|
||||
// distortion, identical to the old fullscreen contain). A CROP band is drawn
|
||||
// ON that picture, never over a re-scaled copy of it.
|
||||
const dispRect = regionBand
|
||||
? { dx: region.x, dy: region.y, dw: region.w, dh: region.h }
|
||||
: imageFitRect;
|
||||
@@ -1603,12 +1698,11 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
}, [selectedFrame, vw, vh, wallLandscape]);
|
||||
// Framed photo window whose content the user may pinch/drag: polaroid card
|
||||
// window or wall-frame opening. Null for plain frames (their whole
|
||||
// composition region is the photo) and for the camera's ratio bands (the
|
||||
// export never crops those through a window).
|
||||
// composition region is the photo), for the camera's ratio bands (the export
|
||||
// never crops those through a window) and for a library CROP (there the BAND
|
||||
// is what the gestures move and the picture stays where it is).
|
||||
const zoomWindowRect = (): { x: number; y: number; w: number; h: number } | null => {
|
||||
// Fixed-ratio crop: the band is the window the picture is positioned in.
|
||||
if (libCropBand)
|
||||
return { x: libCropBand.dx, y: libCropBand.dy, w: libCropBand.dw, h: libCropBand.dh };
|
||||
if (libCropActive) return null;
|
||||
if (polaroidGeo) return polaroidGeo.window;
|
||||
if (selectedFrame === 'wallframe' && wallGeo && wallImage) return wallGeo.window;
|
||||
return null;
|
||||
@@ -1645,19 +1739,18 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
}, [mode, selectedFrame]);
|
||||
|
||||
// Clamp pan/zoom so the photo always keeps COVERING the window: the framed
|
||||
// opening (photo cover-drawn into it) or the ratio-crop band (photo
|
||||
// contain-drawn over the whole viewer). Transform is P' = s*P + t; the rect
|
||||
// the photo is drawn into, [dx,dx+dw], must land over [x, x+w], i.e.
|
||||
// t in [x + w - s*(dx+dw), x - s*dx] (y likewise). At 1x the polaroid/wall
|
||||
// case collapses to t = 0 (the photo already fills its opening exactly),
|
||||
// while a crop band still allows sliding along the overflowing axis.
|
||||
// opening (polaroid card / wall frame) it is cover-drawn into. 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
|
||||
// case collapses to t = 0 (the photo already fills its opening exactly), so
|
||||
// a polaroid/wall window never shows the card through a gap.
|
||||
// A CROP band is NOT one of these windows any more (the band moves, the
|
||||
// picture does not), and zoomWindowRect returns null for it, so this is a
|
||||
// no-op there.
|
||||
const clampZoomToWindow = (s: number, tx: number, ty: number) => {
|
||||
const win = zoomWindowRect();
|
||||
if (!win) return { s, tx, ty }; // plain library/camera: unchanged
|
||||
const src =
|
||||
libCropBand && imageFitRect
|
||||
? { x: imageFitRect.dx, y: imageFitRect.dy, w: imageFitRect.dw, h: imageFitRect.dh }
|
||||
: win;
|
||||
if (!win) return { s, tx, ty }; // plain library/camera/crop: unchanged
|
||||
const src = win;
|
||||
const cl = (v: number, lo: number, hi: number) =>
|
||||
lo > hi ? (lo + hi) / 2 : Math.min(hi, Math.max(lo, v));
|
||||
return {
|
||||
@@ -1990,15 +2083,19 @@ 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 cropDragRef = useRef<
|
||||
| { kind: 'drag'; mode: string; fx: number; fy: number; r: CropRect }
|
||||
| { kind: 'pinch'; d: number; fx: number; fy: number; r: CropRect }
|
||||
| null
|
||||
>(null);
|
||||
const cropHitMode = (x: number, y: number): string | null => {
|
||||
const px = freeCropPx;
|
||||
const px = cropWinPx;
|
||||
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;
|
||||
// Fixed ratio: every touch on the band moves it. An edge drag that scaled
|
||||
// one axis alone would be the one gesture that could break the ratio.
|
||||
if (cropRatioWH != null) return 'move';
|
||||
let m = '';
|
||||
if (Math.abs(y - px.y) <= T) m += 't';
|
||||
else if (Math.abs(y - (px.y + px.h)) <= T) m += 'b';
|
||||
@@ -2013,6 +2110,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const mode = cropHitMode(t.locationX, t.locationY);
|
||||
if (!mode) return;
|
||||
cropDragRef.current = {
|
||||
kind: 'drag',
|
||||
mode,
|
||||
fx: (t.locationX - fit.dx) / fit.dw,
|
||||
fy: (t.locationY - fit.dy) / fit.dh,
|
||||
@@ -2022,7 +2120,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const moveCropDrag = (e: any) => {
|
||||
const d = cropDragRef.current;
|
||||
const fit = imageFitRect;
|
||||
if (!d || !fit || !(fit.dw > 0 && fit.dh > 0)) return;
|
||||
if (!d || d.kind !== 'drag' || !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;
|
||||
@@ -2047,9 +2145,42 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
}
|
||||
onCropRectChange?.({ x: rx, y: ry, w: rw, h: rh });
|
||||
};
|
||||
const beginCropPinch = (ts: any[]) => {
|
||||
const fit = imageFitRect;
|
||||
if (!fit || !(fit.dw > 0 && fit.dh > 0)) return;
|
||||
const d = Math.hypot(ts[0].locationX - ts[1].locationX, ts[0].locationY - ts[1].locationY);
|
||||
if (d < 1) return;
|
||||
const mx = (ts[0].locationX + ts[1].locationX) / 2;
|
||||
const my = (ts[0].locationY + ts[1].locationY) / 2;
|
||||
cropDragRef.current = {
|
||||
kind: 'pinch',
|
||||
d,
|
||||
fx: (mx - fit.dx) / fit.dw,
|
||||
fy: (my - fit.dy) / fit.dh,
|
||||
r: cropRect,
|
||||
};
|
||||
};
|
||||
const moveCropPinch = (ts: any[]) => {
|
||||
const g = cropDragRef.current;
|
||||
if (!g || g.kind !== 'pinch') return;
|
||||
const d = Math.hypot(ts[0].locationX - ts[1].locationX, ts[0].locationY - ts[1].locationY);
|
||||
const k = d / Math.max(g.d, 1);
|
||||
// Scale about the point under the fingers, so the band grows where the
|
||||
// user pinched instead of jumping to the middle.
|
||||
onCropRectChange?.(
|
||||
clampCropRect({
|
||||
x: g.fx - (g.fx - g.r.x) * k,
|
||||
y: g.fy - (g.fy - g.r.y) * k,
|
||||
w: g.r.w * k,
|
||||
h: g.r.h * k,
|
||||
})
|
||||
);
|
||||
};
|
||||
const onLibTouchStart = (e: any) => {
|
||||
if (libFreeCrop) {
|
||||
beginCropDrag(e);
|
||||
if (libCropActive) {
|
||||
const cts = e.nativeEvent.touches;
|
||||
if (cts.length >= 2) beginCropPinch(cts);
|
||||
else beginCropDrag(e);
|
||||
return;
|
||||
}
|
||||
const ts = e.nativeEvent.touches;
|
||||
@@ -2095,8 +2226,9 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
return;
|
||||
}
|
||||
// FRAME tab open: the pinch magnifies the VIEW, not the photo — the
|
||||
// export keeps the framing the user sees at 1x.
|
||||
if (frameTabActive) {
|
||||
// export keeps the framing the user sees at 1x. CROP is the exception:
|
||||
// the band is pinned, so the fingers zoom the PICTURE inside it.
|
||||
if (frameTabActive && !libCropActive) {
|
||||
pinchRef.current = null;
|
||||
return;
|
||||
}
|
||||
@@ -2104,8 +2236,16 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
}
|
||||
};
|
||||
const onLibTouchMove = (e: any) => {
|
||||
if (libFreeCrop) {
|
||||
moveCropDrag(e);
|
||||
if (libCropActive) {
|
||||
const ts = e.nativeEvent.touches;
|
||||
// A second finger that lands after the grant never re-fires START, so
|
||||
// seed the pinch baseline from the first move that sees the pair.
|
||||
if (ts.length >= 2) {
|
||||
if (cropDragRef.current?.kind !== 'pinch') beginCropPinch(ts);
|
||||
moveCropPinch(ts);
|
||||
} else if (cropDragRef.current?.kind === 'drag') {
|
||||
moveCropDrag(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const ts = e.nativeEvent.touches;
|
||||
@@ -2144,8 +2284,9 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
}
|
||||
// FRAME tab: two fingers magnify the whole view — frame, photo and marks
|
||||
// together — so a watermark can be dragged against a big picture. Preview
|
||||
// only; nothing export-facing reads viewZoom.
|
||||
if (ts.length >= 2 && frameTabActive) {
|
||||
// only; nothing export-facing reads viewZoom. Under CROP they fall through
|
||||
// to the seeded pinch below instead — the picture zooms, the band does not.
|
||||
if (ts.length >= 2 && frameTabActive && !libCropActive) {
|
||||
applyViewPinch(ts);
|
||||
pinchUsedRef.current = true;
|
||||
return;
|
||||
@@ -2224,7 +2365,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
}
|
||||
};
|
||||
const onLibTouchEnd = (e: any) => {
|
||||
if (libFreeCrop) {
|
||||
if (libCropActive) {
|
||||
cropDragRef.current = null;
|
||||
return;
|
||||
}
|
||||
@@ -2403,24 +2544,13 @@ 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.
|
||||
// Fixed-ratio CROP, for export: the band IS the crop, in the same 0..1
|
||||
// fractions of the source as the FREE rectangle, so the export engine still
|
||||
// needs to know nothing about bands — and there is no zoom transform left
|
||||
// to un-do either. clampCropRect keeps the reported rect on its ratio even if
|
||||
// the stored value arrived off-aspect (a restored session).
|
||||
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 };
|
||||
})();
|
||||
cropRatioRectRef.current = libCropBand ? clampCropRect(cropRect) : null;
|
||||
|
||||
// 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).
|
||||
@@ -2619,7 +2749,10 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
height={framedWin ? framedWin.h : region.h}
|
||||
opacity={grainOpacity}
|
||||
>
|
||||
<Shader source={noiseEffect} />
|
||||
<Shader
|
||||
source={noiseEffect}
|
||||
uniforms={{ u: (framedWin ? framedWin.w : region.w) / GRAIN_REF }}
|
||||
/>
|
||||
</Rect>
|
||||
</Group>
|
||||
)}
|
||||
@@ -2948,7 +3081,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
<Group blendMode="overlay">
|
||||
<Rect x={win.x} y={win.y} width={win.w} height={win.h} opacity={grainOpacity}>
|
||||
<Shader source={noiseEffect} />
|
||||
<Shader source={noiseEffect} uniforms={{ u: win.w / GRAIN_REF }} />
|
||||
</Rect>
|
||||
</Group>
|
||||
)}
|
||||
@@ -3006,7 +3139,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
<Group blendMode="overlay">
|
||||
<Rect x={win.x} y={win.y} width={win.w} height={win.h} opacity={grainOpacity}>
|
||||
<Shader source={noiseEffect} />
|
||||
<Shader source={noiseEffect} uniforms={{ u: win.w / GRAIN_REF }} />
|
||||
</Rect>
|
||||
</Group>
|
||||
)}
|
||||
@@ -3056,7 +3189,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
{/* Grain covers the whole photo area — the contain rect of the
|
||||
library photo, the ratio band on the camera. */}
|
||||
<Rect x={frameRect.x} y={frameRect.y} width={frameRect.w} height={frameRect.h} opacity={grainOpacity}>
|
||||
<Shader source={noiseEffect} />
|
||||
<Shader source={noiseEffect} uniforms={{ u: frameRect.w / GRAIN_REF }} />
|
||||
</Rect>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -457,14 +457,29 @@ export async function processAndExportPhoto(
|
||||
grainPaint.setBlendMode(BlendMode.Overlay);
|
||||
grainPaint.setAlphaf(grainOpacity);
|
||||
|
||||
// Procedural noise shader
|
||||
// Procedural noise shader — byte-for-byte the preview's (see
|
||||
// Viewfinder's noiseEffect). The cell is `width / 1080` so the grain is
|
||||
// the same size RELATIVE to the picture as in the preview that tuned it;
|
||||
// hashing the raw pixel grid made a 4000px file's grain ~4x finer than
|
||||
// the grain the user had just tuned at 1080px.
|
||||
const noiseEffect = Skia.RuntimeEffect.Make(`
|
||||
uniform float u;
|
||||
float grainHash(vec2 q) {
|
||||
return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
}
|
||||
vec4 main(vec2 pos) {
|
||||
float r = fract(sin(dot(pos.xy, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
return vec4(vec3(r), 1.0);
|
||||
vec2 p = pos.xy / max(u, 1.0);
|
||||
vec2 i = floor(p);
|
||||
vec2 f = p - i;
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
float g00 = grainHash(i);
|
||||
float g10 = grainHash(i + vec2(1.0, 0.0));
|
||||
float g01 = grainHash(i + vec2(0.0, 1.0));
|
||||
float g11 = grainHash(i + vec2(1.0, 1.0));
|
||||
return vec4(vec3(mix(mix(g00, g10, f.x), mix(g01, g11, f.x), f.y)), 1.0);
|
||||
}
|
||||
`);
|
||||
const noiseShader = noiseEffect ? noiseEffect.makeShader([]) : null;
|
||||
const noiseShader = noiseEffect ? noiseEffect.makeShader([width / 1080]) : null;
|
||||
|
||||
if (noiseShader) {
|
||||
grainPaint.setShader(noiseShader);
|
||||
|
||||
Reference in New Issue
Block a user