feat(web): правки применяются на фронте, фильтры в WASM

Новый алгоритм редактирования: обработка идёт только на клиенте.

- Фильтры «Свет» считает WASM-модуль на Rust (frontend/wasm/light, 1.5 КБ):
  он строит тональные кривые и обрабатывает пиксели. При недоступности модуля
  используется JS-реализация с тем же результатом — расхождение проверяется
  тестом.
- Просмотр рисует видимую область фото с правками на canvas поверх
  исходной версии, поэтому изменения видны сразу, без ожидания рендера.
- Правки уходят на сервер не сразу: через десять секунд без изменений или при
  переключении фото. Сервер сохраняет параметры и готовит варианты сетки и
  полноразмерную версию в фоне, о готовности сообщает через SSE — клиент
  подтягивает готовую версию.
This commit is contained in:
iu
2026-10-21 00:06:46 +03:00
parent 4d2069d079
commit 520886a11d
21 changed files with 788 additions and 312 deletions
+122 -121
View File
@@ -9,8 +9,14 @@
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 { edit } from '$lib/stores/edit.svelte';
import { applyLight, initLightWasm } from '$lib/light-wasm';
import {
isEmptyEditParams,
photoFullUrl,
photoPreviewUrl,
readEditParams
} from '$lib/edit';
import type { Photo } from '$lib/types';
interface Props {
@@ -34,7 +40,6 @@
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();
@@ -71,7 +76,31 @@
(current ? photoFullUrl(current) : undefined) ??
''
);
const displaySrc = $derived(zoomed && hiResLoaded && fullSrc ? fullSrc : previewSrc);
const originalSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
// Пока сервер не отрендерил текущие правки, видимую область фото рисует
// сам браузер: правки видны сразу, без ожидания рендера.
const liveParams = $derived(
edit.photoId === current?.id ? edit.params : readEditParams(current)
);
const hasEdits = $derived(!isEmptyEditParams(liveParams));
const editUrl = $derived(fresh?.edit_url ?? current?.edit_url ?? '');
const savedParams = $derived(readEditParams(current));
const pending = $derived(
edit.photoId === current?.id &&
JSON.stringify(edit.params) !== JSON.stringify(savedParams)
);
const needClientRender = $derived(hasEdits && (pending || !editUrl));
const clientPreview = $derived(zoomed && needClientRender);
// Пока браузер рисует правки сам, под ним лежит исходная версия фото.
const displaySrc = $derived(
clientPreview
? originalSrc || previewSrc
: zoomed && hiResLoaded && fullSrc
? fullSrc
: previewSrc
);
const panoTag = $derived(isPanoTag(current) || isPanoTag(fresh));
const pano = $derived(panoTag && !panoFailed);
const panoSrc = $derived(fullSrc || previewSrc);
@@ -84,6 +113,9 @@
$effect(() => {
// Пока открыт просмотр, клавиши `+`/`-` относятся к зуму фото.
viewer.open = true;
// Заранее поднимаем WASM-модуль фильтров: предпросмотр правок должен
// быть готов сразу.
void initLightWasm();
return () => {
viewer.open = false;
};
@@ -112,15 +144,11 @@
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();
@@ -143,85 +171,67 @@
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 canvas: HTMLCanvasElement | undefined = $state();
let viewportFrame: number | null = null;
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() {
/** Рисует видимую область фото с применёнными правками. */
function renderViewport() {
const c = canvas;
const img = imgEl;
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 {
/* офлайн: остаётся прежний кусок */
if (!c || !img || !el) return;
const natural = img.naturalWidth;
if (!natural) return;
const ratio = img.clientWidth / natural;
if (!ratio) return;
const width = el.clientWidth;
const height = el.clientHeight;
if (!width || !height) return;
if (c.width !== width || c.height !== height) {
c.width = width;
c.height = height;
}
c.style.width = `${width}px`;
c.style.height = `${height}px`;
const ctx = c.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, width, height);
ctx.drawImage(
img,
el.scrollLeft / ratio,
el.scrollTop / ratio,
width / ratio,
height / ratio,
0,
0,
width,
height
);
const data = ctx.getImageData(0, 0, width, height);
applyLight(data.data, liveParams.light);
ctx.putImageData(data, 0, 0);
}
function scheduleRegion() {
if (!zoomed || !edited) {
clearRegion();
return;
}
if (regionTimer) clearTimeout(regionTimer);
regionTimer = setTimeout(() => {
regionTimer = null;
void loadRegion();
}, 120);
function scheduleViewport() {
if (!clientPreview) return;
if (viewportFrame !== null) return;
viewportFrame = requestAnimationFrame(() => {
viewportFrame = null;
renderViewport();
});
}
$effect(() => {
// Перерисовываем вьюпорт при смене зума, правок или источника фото.
zoomed;
scale;
liveParams.light;
displaySrc;
scheduleViewport();
});
$effect(() => {
const onFs = () => {
isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl;
@@ -305,9 +315,9 @@
return fresh?.height || current?.height || imgEl?.naturalHeight || 0;
}
/** Элемент, занимающий весь кадр фото: картинка или рамка с куском. */
/** Элемент, занимающий весь кадр фото. */
function frameElement(): HTMLElement | undefined {
return frameEl ?? imgEl ?? undefined;
return imgEl ?? undefined;
}
function prev() {
@@ -359,7 +369,7 @@
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();
scheduleViewport();
});
}
function next() {
@@ -370,7 +380,6 @@
if (zoomed) {
zoomed = false;
scale = 1;
clearRegion();
if (scroller) {
scroller.scrollLeft = 0;
scroller.scrollTop = 0;
@@ -382,7 +391,7 @@
if (!scroller) return;
scroller.scrollLeft = (scroller.scrollWidth - scroller.clientWidth) / 2;
scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) / 2;
scheduleRegion();
scheduleViewport();
});
}
}
@@ -415,7 +424,7 @@
const sr = scroller.getBoundingClientRect();
scroller.scrollLeft = fx * natW * newScale - (ev.clientX - sr.left);
scroller.scrollTop = fy * natH * newScale - (ev.clientY - sr.top);
scheduleRegion();
scheduleViewport();
});
}
@@ -446,7 +455,7 @@
} catch {
/* ignore */
}
scheduleRegion();
scheduleViewport();
}
async function toggleViewerFullscreen() {
@@ -652,40 +661,24 @@
{/if}
</div>
{:else}
<div
bind:this={scroller}
class="bg-canvas flex min-h-0 flex-1 overscroll-contain {zoomed
? 'cursor-grab overflow-auto'
: 'overflow-hidden'}"
role="group"
aria-label="Просмотр фото"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
oncontextmenu={onContextMenu}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
onscroll={scheduleRegion}
>
{#if current}
{#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}
<div class="relative flex min-h-0 flex-1">
<div
bind:this={scroller}
class="bg-canvas flex min-h-0 flex-1 overscroll-contain {zoomed
? 'cursor-grab overflow-auto'
: 'overflow-hidden'}"
role="group"
aria-label="Просмотр фото"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
oncontextmenu={onContextMenu}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
onscroll={scheduleViewport}
>
{#if current}
<img
bind:this={imgEl}
src={displaySrc}
@@ -697,6 +690,14 @@
: 'max-h-full max-w-full object-contain'}"
/>
{/if}
</div>
{#if clientPreview}
<!-- Правки видны сразу: браузер рисует видимую область, пока
сервер готовит отредактированную версию фото. -->
<canvas
bind:this={canvas}
class="pointer-events-none absolute inset-0 z-10 select-none"
></canvas>
{/if}
</div>
{/if}