feat(web): правки применяются на фронте, фильтры в WASM
Новый алгоритм редактирования: обработка идёт только на клиенте. - Фильтры «Свет» считает WASM-модуль на Rust (frontend/wasm/light, 1.5 КБ): он строит тональные кривые и обрабатывает пиксели. При недоступности модуля используется JS-реализация с тем же результатом — расхождение проверяется тестом. - Просмотр рисует видимую область фото с правками на canvas поверх исходной версии, поэтому изменения видны сразу, без ожидания рендера. - Правки уходят на сервер не сразу: через десять секунд без изменений или при переключении фото. Сервер сохраняет параметры и готовит варианты сетки и полноразмерную версию в фоне, о готовности сообщает через SSE — клиент подтягивает готовую версию.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user