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
@@ -1,6 +1,7 @@
<script lang="ts">
import { photosApi } from '$lib/api';
import Icon from './Icon.svelte';
import { edit } from '$lib/stores/edit.svelte';
import {
CHANNEL_COLORS,
CHANNEL_LABELS,
@@ -31,8 +32,9 @@
let channel = $state<CurveChannel>('rgb');
const saved = $derived(readEditParams(photo));
const hasEdits = $derived(!isEmptyEditParams(saved));
const dirty = $derived(JSON.stringify(params) !== JSON.stringify(saved));
// Кнопка сброса активна и для сохранённых, и для ещё не отправленных правок.
const hasEdits = $derived(!isEmptyEditParams(saved) || !isEmptyEditParams(params));
// Правки сохранены, но отредактированная версия ещё рендерится.
const rendering = $derived(hasEdits && !photo.edit_preview_url);
@@ -43,15 +45,55 @@
if (id === lastPhotoId) return;
lastPhotoId = id;
params = readEditParams(photo);
edit.set(id, params);
error = '';
return () => {
// Фото переключилось: отправляем накопленные правки.
flushSave();
};
});
async function save(next: EditParams) {
// Правки уходят на сервер не сразу: пока пользователь крутит ползунки,
// фото перерисовывает браузер, а серверу достаётся уже settled-версия.
const SAVE_DELAY = 10_000;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let pendingId = $state('');
let pendingParams = $state<EditParams | null>(null);
function scheduleSave() {
pendingId = photo.id;
pendingParams = params;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
void flushSave();
}, SAVE_DELAY);
}
async function flushSave() {
if (!saveTimer && !pendingParams) return;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
const id = pendingId;
const next = pendingParams ? normalizeEditParams(pendingParams) : null;
pendingId = '';
pendingParams = null;
if (!id || !next) return;
if (isEmptyEditParams(next)) {
await resetEdit(id);
return;
}
await saveTo(id, next);
}
async function saveTo(id: string, next: EditParams) {
if (busy) return;
busy = true;
error = '';
try {
const res = await photosApi.edit(photo.id, next);
const res = await photosApi.edit(id, next);
onUpdated(res.photo);
} catch (e) {
error = (e as Error).message;
@@ -62,17 +104,17 @@
function onSliderInput(key: (typeof LIGHT_SLIDERS)[number]['key'], value: number) {
params = { ...params, light: { ...params.light, [key]: value } };
// Живые параметры нужны просмотрщику: он рисует видимую область фото
// с правками, пока сервер не отрендерил версию.
edit.set(photo.id, params);
}
function onSliderCommit() {
if (!canEdit) return;
const next = normalizeEditParams(params);
params = next;
if (isEmptyEditParams(next)) {
reset();
return;
}
void save(next);
params = normalizeEditParams(params);
edit.set(photo.id, params);
// На сервер отправляем не сразу: ждём, пока пользователь закончит.
scheduleSave();
}
function setChannel(next: CurveChannel) {
@@ -123,6 +165,7 @@
...params,
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
};
edit.set(photo.id, params);
dragging = index;
}
@@ -136,6 +179,7 @@
...params,
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
};
edit.set(photo.id, params);
}
function onPointUp() {
@@ -167,6 +211,7 @@
...params,
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
};
edit.set(photo.id, params);
onSliderCommit();
}
@@ -177,16 +222,18 @@
...params,
light: { ...params.light, curve: { ...params.light.curve, [ch]: undefined } }
};
edit.set(photo.id, params);
onSliderCommit();
}
async function reset() {
async function resetEdit(id: string) {
if (busy) return;
busy = true;
error = '';
try {
const res = await photosApi.resetEdit(photo.id);
const res = await photosApi.resetEdit(id);
params = readEditParams(res.photo);
edit.set(id, params);
onUpdated(res.photo);
} catch (e) {
error = (e as Error).message;
@@ -198,7 +245,13 @@
// Сброс доступен, когда у фото есть правки.
async function toggleReset() {
if (!hasEdits) return;
await reset();
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
pendingId = '';
pendingParams = null;
await resetEdit(photo.id);
}
const sliderClass = 'accent-primary h-1 w-full cursor-pointer';
@@ -334,16 +387,17 @@
</div>
{#if busy}
<p class="text-text-subtle text-xs">Сохраняем…</p>
<p class="text-text-subtle text-xs">Сохраняем на сервере…</p>
{:else if dirty && !busy}
<p class="text-text-subtle text-xs">
Правки видны сразу, на сервер уйдут через 10 секунд без изменений
</p>
{:else if rendering}
<p class="text-text-subtle text-xs">Рендерим правки…</p>
<p class="text-text-subtle text-xs">Рендерим правки на сервере…</p>
{/if}
{#if error}
<p class="text-danger text-xs">{error}</p>
{/if}
{#if dirty && !busy}
<p class="text-text-subtle text-xs">Есть несохранённые изменения</p>
{/if}
</div>
{/if}
</div>