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:
2026-09-16 17:17:36 +07:00
parent 8f615f6cd7
commit 7b013fb6f8
4 changed files with 242 additions and 84 deletions
+5
View File
@@ -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 */}
+5
View File
@@ -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
View File
@@ -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>
)}
+19 -4
View File
@@ -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);