feat(photos): рендер только видимой области при зуме правок
Вместо полноразмерной отредактированной версии просмотр запрашивает у сервера только видимую область кадра: координаты относительные, поэтому запрос не зависит от размера оригинала. Рендер куска занимает около 0.15 с против 0.8 с для всей картинки, а полноразмерная версия больше не готовится вовсе. Зум больше не сбрасывается, когда приходит отрендеренная версия правок: состояние просмотра сбрасывается только при смене самого фото.
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user