From 9607f9594ec3a80aefe3ba775b1390dc20824afb Mon Sep 17 00:00:00 2001 From: 3dtours Date: Thu, 15 Oct 2026 18:55:41 +0700 Subject: [PATCH] fix(studio): the zoomed stage keeps the frame's own pixels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview copy behind the stage stopped at PREVIEW_HARD_MAX (3200) whatever the photo was. A 5729px frame opened from Immich, zoomed past 3200 device pixels, was painted from a 3200px copy stretched over the screen — soft enough for a reader to call it the thumbnail having opened. The ceiling is now the photo's own longest edge. `resizedJpeg` never grows a photo, so at that size it hands the original bytes straight back, and the copy still only grows when the stage says it needs it: at fitting size the 1600px one stands and the edit keeps its old cost. Measured on the operator's own Immich asset (5729x3820, retina 2x, opened on the stage and zoomed 12 notches in): the stage held 3200x2134 before and 5729x3820 after. --- docker/frontend/src/App.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 77060c9..f09d2ba 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -589,11 +589,19 @@ export function Workspace() { // 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); + // What the stage may be asked for, in device pixels: the photo's own longest + // edge once that is bigger than the cap. `resizedJpeg` never grows a photo, + // so at this size it hands the original bytes straight back — a frame zoomed + // past 3200 is then the frame itself rather than a 3200px copy stretched over + // the screen, which is the blur a reader reads as the thumbnail having been + // opened. The copy still only grows when the stage says it needs it: at + // fitting size the 1600px one stands, and the edit keeps its old cost. + const previewCeiling = Math.max(PREVIEW_HARD_MAX, photoDims ? Math.max(photoDims.w, photoDims.h) : 0); const onStageZoom = useCallback((longDevicePx: number) => { setZoomNeedPx( - Math.max(PREVIEW_MAX, Math.min(PREVIEW_HARD_MAX, Math.ceil(longDevicePx / PREVIEW_QUANTUM) * PREVIEW_QUANTUM)) + Math.max(PREVIEW_MAX, Math.min(previewCeiling, Math.ceil(longDevicePx / PREVIEW_QUANTUM) * PREVIEW_QUANTUM)) ); - }, []); + }, [previewCeiling]); const cropEditing = crop !== 'none' && !cropApplied; const cropLive = crop !== 'none' && cropApplied; // A committed crop keeps only part of the photo, so the 1600px copy leaves @@ -636,7 +644,7 @@ export function Workspace() { })(); const cropWH = crop !== 'none' && crop !== 'free' ? CROP_W_H[crop] : null; const previewMax = Math.min( - PREVIEW_HARD_MAX, + previewCeiling, Math.max( zoomNeedPx, cropLive