From 99be7e96bd20f3d1136cdb0c237cfca2a2fca57b Mon Sep 17 00:00:00 2001 From: 3dtours Date: Thu, 17 Sep 2026 21:17:54 +0700 Subject: [PATCH] Size the preview copy to what the stage actually paints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The copy was fixed at 1600px, so a hidpi screen was already stretching it at 1:1 and every wheel notch made it worse. The stage now reports the size it is painting at — the contain-fit times the pixel ratio times the zoom — and the app re-cuts the source copy to match, quantised and capped at 3200px, which is the largest copy the grade can still afford. The wheel keeps its instant transform; the sharper copy lands once the gesture stops. --- docker/frontend/src/App.tsx | 35 ++++++++++++++++++++------- docker/frontend/src/ui/ImageStage.tsx | 25 ++++++++++++++++--- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 1240199..4a028db 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -33,9 +33,13 @@ const PREVIEW_MAX = 1600; // A committed crop keeps only a fraction of the photo, so the 1600px copy // leaves too few pixels behind and the result reads as blocky once the stage // scales it up. While a crop is live the source is re-derived larger, inverted -// by that fraction, and capped here so the decode stays bounded. -const CROP_PREVIEW_MAX = 3200; +// by that fraction. The same ceiling bounds a zoomed-in stage, and 3200px is +// what the decode can afford: past it a wheel gesture stops keeping up. +const PREVIEW_HARD_MAX = 3200; const CROP_PREVIEW_MIN_FRAC = 0.05; +// Zoom asks for a bigger copy; quantised so a smooth gesture does not re-cut it +// on every notch. +const PREVIEW_QUANTUM = 512; const PREVIEW_DEBOUNCE_MS = 120; const FRAMES: { id: FrameId; label: MsgKey }[] = [ @@ -169,6 +173,13 @@ export function Workspace() { // Crop has three states: none, framed-but-uncommitted (the frame is draggable // and the whole photo is shown), and applied (the crop alone is rendered). + // What the stage says it is painting at; PREVIEW_MAX until it reports. + const [zoomNeedPx, setZoomNeedPx] = useState(PREVIEW_MAX); + const onStageZoom = useCallback((longDevicePx: number) => { + setZoomNeedPx( + Math.max(PREVIEW_MAX, Math.min(PREVIEW_HARD_MAX, Math.ceil(longDevicePx / PREVIEW_QUANTUM) * PREVIEW_QUANTUM)) + ); + }, []); const cropEditing = crop !== 'none' && !cropApplied; const cropLive = crop !== 'none' && cropApplied; const cropWH = crop !== 'none' && crop !== 'free' ? CROP_W_H[crop] : null; @@ -187,12 +198,15 @@ export function Workspace() { const long = Math.max(d?.w ?? 0, d?.h ?? 0); return long > 0 ? f / long : Math.min(cropRect.w, cropRect.h); })(); - const previewMax = cropLive - ? Math.min( - CROP_PREVIEW_MAX, - Math.max(PREVIEW_MAX, Math.round(PREVIEW_MAX / Math.max(cropLongFrac, CROP_PREVIEW_MIN_FRAC))) - ) - : PREVIEW_MAX; + const previewMax = Math.min( + PREVIEW_HARD_MAX, + Math.max( + zoomNeedPx, + cropLive + ? Math.max(PREVIEW_MAX, Math.round(PREVIEW_MAX / Math.max(cropLongFrac, CROP_PREVIEW_MIN_FRAC))) + : PREVIEW_MAX + ) + ); // --- undo ----------------------------------------------------------------- // The stack holds whole looks. remember() reads a ref written during render, @@ -375,8 +389,10 @@ export function Workspace() { if (prev) URL.revokeObjectURL(prev.url); return { bytes, name, url: URL.createObjectURL(blob) }; }); - // The new photo's copy is the 1600px one; a live crop then upgrades it. + // The new photo's copy is the 1600px one; a live crop or a zoomed stage + // then upgrades it. previewMaxRef.current = PREVIEW_MAX; + setZoomNeedPx(PREVIEW_MAX); previewJobRef.current++; setPreviewBytes(preview); setGps(found); @@ -1155,6 +1171,7 @@ export function Workspace() { cropRect={cropRect} cropWH={cropWH} onCropRectChange={setCropRect} + onStageZoom={onStageZoom} /> diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index e246bb9..3bf01fa 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -2,11 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useI18n } from '../i18n/I18nProvider'; import { MIN_CROP_FRAC, type CropRect } from '../../shared/types'; -// One wheel notch, and how far the photo may be pulled open. Past 1 the render -// is the 1600px copy, so deep zoom is soft by design — it is for reading -// framing, not for judging grain. +// One wheel notch, and how far the photo may be pulled open. const ZOOM_STEP = 1.15; const ZOOM_MAX = 8; +// How long the wheel has to rest before the stage asks for a sharper copy. +const ZOOM_SETTLE_MS = 160; // The centre column: drop target, preview canvas, and the PEEK comparison. // Dragging a photo anywhere on the page works, not just on the frame — the @@ -23,6 +23,7 @@ export function ImageStage({ cropRect, cropWH = null, onCropRectChange, + onStageZoom, }: { previewUrl: string | null; originalUrl: string | null; @@ -37,6 +38,10 @@ export function ImageStage({ // Wanted pixel aspect (w/h) for a fixed ratio, null for FREE. cropWH?: number | null; onCropRectChange?: (r: CropRect) => void; + // Longest side, in device pixels, the stage is actually painting the photo + // at — the app re-cuts its preview copy to match. Scaled to contain the + // photo and multiplied by the zoom and the screen's pixel ratio. + onStageZoom?: (longDevicePx: number) => void; }) { const { t } = useI18n(); const inputRef = useRef(null); @@ -201,6 +206,20 @@ export function ImageStage({ return () => window.removeEventListener('resize', onResize); }, [clampPan]); + // Report the size the stage is painting at so the app can re-cut the preview + // copy to match — otherwise a retina screen already stretches it at 1:1 and + // zooming only makes that worse. Debounced: the wheel keeps its instant + // transform and the sharper copy lands once the gesture stops. + useEffect(() => { + if (!onStageZoom) return; + const timer = setTimeout(() => { + const b = baseRect(); + if (!b) return; + onStageZoom(Math.round(Math.max(b.w, b.h) * (window.devicePixelRatio || 1) * viewRef.current.s)); + }, ZOOM_SETTLE_MS); + return () => clearTimeout(timer); + }, [onStageZoom, baseRect, view.s, previewUrl, cropEditing]); + const startPan = (e: React.PointerEvent) => { if (cropEditing || viewRef.current.s <= 1 || e.button !== 0) return; e.preventDefault();