diff --git a/App.tsx b/App.tsx index 5f6c445..492ef58 100644 --- a/App.tsx +++ b/App.tsx @@ -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(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 */} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 188635b..dd940e4 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -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({ <> onHeight?.(e.nativeEvent.layout.height)} className="absolute top-0 left-0 right-0 z-20 flex-row items-start justify-between px-4" > diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index e86f877..49d349e 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -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(function Viewfi gpsInfo, libraryImageUri, availableHeight, + topInset, cameraPermissionGranted, onRequestCameraPermission, photoOutput, @@ -293,6 +317,11 @@ const Viewfinder = forwardRef(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(function Viewfi } }; const onLibTouchEnd = (e: any) => { - if (libFreeCrop) { + if (libCropActive) { cropDragRef.current = null; return; } @@ -2403,24 +2544,13 @@ const Viewfinder = forwardRef(function Viewfi }; })(); - // Fixed-ratio CROP, for export: the export crops to its own band, but the - // user may have pushed the picture around inside it, so report the part of - // the photo the band is currently SHOWING as fractions of the - // (post-rotation) source — the same currency as the FREE rectangle, so the - // export engine needs to know nothing about bands or zoom. + // 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(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(function Viewfi height={framedWin ? framedWin.h : region.h} opacity={grainOpacity} > - + )} @@ -2948,7 +3081,7 @@ const Viewfinder = forwardRef(function Viewfi {grainOpacity > 0 && noiseEffect && ( - + )} @@ -3006,7 +3139,7 @@ const Viewfinder = forwardRef(function Viewfi {grainOpacity > 0 && noiseEffect && ( - + )} @@ -3056,7 +3189,7 @@ const Viewfinder = forwardRef(function Viewfi {/* Grain covers the whole photo area — the contain rect of the library photo, the ratio band on the camera. */} - + )} diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index 2b890ea..0723db2 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -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);