feat(photos): рендер только видимой области при зуме правок

Вместо полноразмерной отредактированной версии просмотр запрашивает у сервера
только видимую область кадра: координаты относительные, поэтому запрос не
зависит от размера оригинала. Рендер куска занимает около 0.15 с против 0.8 с
для всей картинки, а полноразмерная версия больше не готовится вовсе.

Зум больше не сбрасывается, когда приходит отрендеренная версия правок:
состояние просмотра сбрасывается только при смене самого фото.
This commit is contained in:
iu
2026-10-20 23:34:45 +03:00
parent 6d3d7cbdd4
commit 4d2069d079
9 changed files with 328 additions and 40 deletions
+152 -26
View File
@@ -9,6 +9,7 @@
import { transfer } from '$lib/stores/transfer.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import { viewer } from '$lib/stores/viewer.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { photoFullUrl, photoPreviewUrl } from '$lib/edit';
import type { Photo } from '$lib/types';
@@ -33,6 +34,7 @@
let viewerEl: HTMLDivElement | undefined = $state();
let scroller: HTMLDivElement | undefined = $state();
let imgEl: HTMLImageElement | undefined = $state();
let frameEl: HTMLDivElement | undefined = $state();
let topEl: HTMLDivElement | undefined = $state();
let bottomEl: HTMLDivElement | undefined = $state();
@@ -92,20 +94,33 @@
// Версия фото входит в зависимость: после рендера правок превью
// меняется, и данные нужно перечитать.
current?.edit_preview_url ?? current?.preview_url ?? '';
fresh = null;
zoomed = false;
scale = 1;
hiResLoaded = false;
panoFailed = false;
if (!id) return;
api<{ photo: Photo }>(`/photos/${id}`)
.then((data) => (fresh = data.photo))
.catch(() => {});
});
// Состояние просмотра сбрасывается только при смене фото: зум не должен
// сбрасываться, когда приходит отрендеренная версия правок.
let lastPhotoId = '';
$effect(() => {
const id = current?.id;
if (id === lastPhotoId) return;
lastPhotoId = id;
fresh = null;
zoomed = false;
scale = 1;
hiResLoaded = false;
panoFailed = false;
clearRegion();
});
// In zoom mode, preload the full-resolution original and swap it in once loaded.
$effect(() => {
if (!zoomed || hiResLoaded) return;
// Для отредактированных фото зум собирается из кусков, полная версия
// не нужна.
if (edited) return;
const url = fullSrc;
if (!url) return;
const im = new Image();
@@ -128,6 +143,85 @@
return () => el.removeEventListener('wheel', handler);
});
// Фото с правками в зуме показываем кусками: сервер рендерит только видимую
// область, это быстрее, чем кодировать всю картинку целиком.
const edited = $derived(!!current?.edit_params && Object.keys(current.edit_params).length > 0);
const frameWidth = $derived(zoomed ? baseWidth() * scale : 0);
const frameHeight = $derived(zoomed ? baseHeight() * scale : 0);
let regionURL = $state('');
let regionRect = $state<{ left: number; top: number; width: number; height: number } | null>(
null
);
let regionTimer: ReturnType<typeof setTimeout> | null = null;
let regionToken = 0;
function clamp01(v: number): number {
return Math.min(1, Math.max(0, v));
}
function clearRegion() {
regionToken += 1;
if (regionTimer) {
clearTimeout(regionTimer);
regionTimer = null;
}
if (regionURL) {
URL.revokeObjectURL(regionURL);
regionURL = '';
}
regionRect = null;
}
async function loadRegion() {
const el = scroller;
const id = current?.id;
if (!el || !id || !zoomed || !edited) return;
const fw = frameWidth;
const fh = frameHeight;
if (!fw || !fh) return;
const x = clamp01(el.scrollLeft / fw);
const y = clamp01(el.scrollTop / fh);
// Берём область с запасом, чтобы мелкая прокрутка не требовала нового
// запроса к серверу.
const w = Math.min(1, Math.max(0.05, (el.clientWidth / fw) * 1.5));
const h = Math.min(1, Math.max(0.05, (el.clientHeight / fh) * 1.5));
const rx = clamp01(x - (w - el.clientWidth / fw) / 2);
const ry = clamp01(y - (h - el.clientHeight / fh) / 2);
const token = ++regionToken;
try {
const res = await fetch(
`/api/photos/${id}/edit/region?x=${rx.toFixed(4)}&y=${ry.toFixed(4)}&w=${w.toFixed(4)}&h=${h.toFixed(4)}`,
{ headers: auth.access ? { Authorization: `Bearer ${auth.access}` } : {} }
);
if (!res.ok) {
// Без доступа к области показываем обычное фото.
clearRegion();
return;
}
const blob = await res.blob();
if (token !== regionToken) return;
const url = URL.createObjectURL(blob);
if (regionURL) URL.revokeObjectURL(regionURL);
regionURL = url;
regionRect = { left: rx * fw, top: ry * fh, width: w * fw, height: h * fh };
} catch {
/* офлайн: остаётся прежний кусок */
}
}
function scheduleRegion() {
if (!zoomed || !edited) {
clearRegion();
return;
}
if (regionTimer) clearTimeout(regionTimer);
regionTimer = setTimeout(() => {
regionTimer = null;
void loadRegion();
}, 120);
}
$effect(() => {
const onFs = () => {
isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl;
@@ -211,6 +305,11 @@
return fresh?.height || current?.height || imgEl?.naturalHeight || 0;
}
/** Элемент, занимающий весь кадр фото: картинка или рамка с куском. */
function frameElement(): HTMLElement | undefined {
return frameEl ?? imgEl ?? undefined;
}
function prev() {
if (index > 0) onIndexChange(index - 1);
}
@@ -230,7 +329,8 @@
*/
function zoomBy(factor: number) {
const el = scroller;
if (!el || !imgEl) return;
const frame = frameElement();
if (!el || !frame) return;
const natW = baseWidth();
const natH = baseHeight();
if (!natW || !natH) return;
@@ -240,24 +340,26 @@
const next = Math.min(MAX_ZOOM, Math.max(fit, cur * factor));
if (Math.abs(next - cur) < 1e-4) return;
const relX = imgEl.offsetWidth
? (el.scrollLeft + el.clientWidth / 2 - imgEl.offsetLeft) / imgEl.offsetWidth
const relX = frame.offsetWidth
? (el.scrollLeft + el.clientWidth / 2 - frame.offsetLeft) / frame.offsetWidth
: 0.5;
const relY = imgEl.offsetHeight
? (el.scrollTop + el.clientHeight / 2 - imgEl.offsetTop) / imgEl.offsetHeight
const relY = frame.offsetHeight
? (el.scrollTop + el.clientHeight / 2 - frame.offsetTop) / frame.offsetHeight
: 0.5;
zoomed = next > fit * 1.001;
scale = next;
tick().then(() => {
const iw = imgEl?.offsetWidth ?? 0;
const ih = imgEl?.offsetHeight ?? 0;
const frame = frameElement();
const iw = frame?.offsetWidth ?? 0;
const ih = frame?.offsetHeight ?? 0;
if (!iw || !ih) return;
const offX = Math.max(0, (el.clientWidth - iw) / 2);
const offY = Math.max(0, (el.clientHeight - ih) / 2);
el.scrollLeft = offX + relX * iw - el.clientWidth / 2;
el.scrollTop = offY + relY * ih - el.clientHeight / 2;
scheduleRegion();
});
}
function next() {
@@ -268,6 +370,7 @@
if (zoomed) {
zoomed = false;
scale = 1;
clearRegion();
if (scroller) {
scroller.scrollLeft = 0;
scroller.scrollTop = 0;
@@ -279,12 +382,14 @@
if (!scroller) return;
scroller.scrollLeft = (scroller.scrollWidth - scroller.clientWidth) / 2;
scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) / 2;
scheduleRegion();
});
}
}
function onWheel(ev: WheelEvent) {
if (!current || !scroller || !imgEl) return;
const frame = frameElement();
if (!current || !scroller || !frame) return;
ev.preventDefault();
ev.stopPropagation();
@@ -298,9 +403,9 @@
const newScale = Math.min(MAX_ZOOM, Math.max(fit, curScale * factor));
if (Math.abs(newScale - curScale) < 1e-4) return;
const r = imgEl.getBoundingClientRect();
const fx = (ev.clientX - r.left) / r.width;
const fy = (ev.clientY - r.top) / r.height;
const r = frame.getBoundingClientRect();
const fx = r.width ? (ev.clientX - r.left) / r.width : 0.5;
const fy = r.height ? (ev.clientY - r.top) / r.height : 0.5;
zoomed = true;
scale = newScale;
@@ -310,6 +415,7 @@
const sr = scroller.getBoundingClientRect();
scroller.scrollLeft = fx * natW * newScale - (ev.clientX - sr.left);
scroller.scrollTop = fy * natH * newScale - (ev.clientY - sr.top);
scheduleRegion();
});
}
@@ -340,6 +446,7 @@
} catch {
/* ignore */
}
scheduleRegion();
}
async function toggleViewerFullscreen() {
@@ -559,18 +666,37 @@
oncontextmenu={onContextMenu}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
onscroll={scheduleRegion}
>
{#if current}
<img
bind:this={imgEl}
src={displaySrc}
alt={current.original_filename}
draggable="false"
style={zoomed ? `width: ${baseWidth() * scale}px; height: auto;` : ''}
class="m-auto block {zoomed
? 'max-w-none shrink-0 select-none'
: 'max-h-full max-w-full object-contain'}"
/>
{#if regionURL && regionRect}
<!-- Отредактированное фото в зуме: сервер отрендерил только
видимую область, остальное дорисовывается при прокрутке. -->
<div
bind:this={frameEl}
class="relative m-auto shrink-0"
style="width: {frameWidth}px; height: {frameHeight}px;"
>
<img
src={regionURL}
alt={current.original_filename}
draggable="false"
class="absolute max-w-none select-none"
style="left: {regionRect.left}px; top: {regionRect.top}px; width: {regionRect.width}px; height: {regionRect.height}px;"
/>
</div>
{:else}
<img
bind:this={imgEl}
src={displaySrc}
alt={current.original_filename}
draggable="false"
style={zoomed ? `width: ${baseWidth() * scale}px; height: auto;` : ''}
class="m-auto block {zoomed
? 'max-w-none shrink-0 select-none'
: 'max-h-full max-w-full object-contain'}"
/>
{/if}
{/if}
</div>
{/if}