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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -183,3 +183,80 @@ export function photoPreviewUrl(photo: Photo): string | undefined {
|
||||
export function photoFullUrl(photo: Photo): string | undefined {
|
||||
return photo.edit_url ?? photo.original_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит 256-элементную таблицу соответствия по точкам кривой. Математика
|
||||
* повторяет серверную (backend/internal/photos/edit.go), поэтому предпросмотр
|
||||
* в браузере совпадает с результатом рендера.
|
||||
*/
|
||||
export function buildCurveLUT(points?: CurvePoint[]): Uint8Array {
|
||||
const lut = new Uint8Array(256);
|
||||
const pts = normalizeCurve(points);
|
||||
if (!pts || pts.length < 2) {
|
||||
for (let i = 0; i < 256; i++) lut[i] = i;
|
||||
return lut;
|
||||
}
|
||||
for (let i = 0; i < 256; i++) {
|
||||
lut[i] = Math.round(clamp01(curveEval(pts, i / 255)) * 255);
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
function curveEval(points: CurvePoint[], x: number): number {
|
||||
if (x <= points[0].x) return points[0].y;
|
||||
const last = points[points.length - 1];
|
||||
if (x >= last.x) return last.y;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (x > points[i].x) continue;
|
||||
const prev = points[i - 1];
|
||||
const cur = points[i];
|
||||
if (cur.x - prev.x < 1e-9) return cur.y;
|
||||
const t = (x - prev.x) / (cur.x - prev.x);
|
||||
return prev.y + t * (cur.y - prev.y);
|
||||
}
|
||||
return last.y;
|
||||
}
|
||||
|
||||
function adjustChannel(
|
||||
value: number,
|
||||
light: LightParams,
|
||||
master: Uint8Array,
|
||||
channel: Uint8Array
|
||||
): number {
|
||||
// Экспозиция (стопы).
|
||||
let v = value * Math.pow(2, light.exposure);
|
||||
// Контраст вокруг средней точки.
|
||||
const k = 1 + light.contrast / 100;
|
||||
v = 128 + (v - 128) * k;
|
||||
// Света: верхняя часть диапазона.
|
||||
let w = Math.pow(clamp01((v - 128) / 127), 2);
|
||||
v += (light.highlights / 100) * 80 * w;
|
||||
// Тени: нижняя часть диапазона.
|
||||
w = Math.pow(clamp01((128 - v) / 128), 2);
|
||||
v += (light.shadows / 100) * 80 * w;
|
||||
// Белые: самый верх диапазона.
|
||||
w = clamp01((v - 192) / 63);
|
||||
v += (light.whites / 100) * 80 * w;
|
||||
// Чёрные: самый низ диапазона.
|
||||
w = clamp01((64 - v) / 64);
|
||||
v += (light.blacks / 100) * 80 * w;
|
||||
// Тоновая кривая: общая, затем канальная.
|
||||
let idx = Math.min(255, Math.max(0, Math.round(v)));
|
||||
v = master[idx];
|
||||
idx = Math.min(255, Math.max(0, Math.round(v)));
|
||||
return channel[idx];
|
||||
}
|
||||
|
||||
/** Применяет правки «Свет» к пикселям изображения (в том же массиве). */
|
||||
export function applyLightToPixels(data: Uint8ClampedArray, light: LightParams): void {
|
||||
const master = buildCurveLUT(light.curve?.rgb);
|
||||
const red = buildCurveLUT(light.curve?.red);
|
||||
const green = buildCurveLUT(light.curve?.green);
|
||||
const blue = buildCurveLUT(light.curve?.blue);
|
||||
for (let i = 0; i + 3 < data.length; i += 4) {
|
||||
if (data[i + 3] === 0) continue;
|
||||
data[i] = adjustChannel(data[i], light, master, red);
|
||||
data[i + 1] = adjustChannel(data[i + 1], light, master, green);
|
||||
data[i + 2] = adjustChannel(data[i + 2], light, master, blue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { applyLightToPixels, emptyEditParams, type LightParams } from './edit';
|
||||
|
||||
interface LightExports {
|
||||
memory: WebAssembly.Memory;
|
||||
build_lut(points: number, count: number, channel: number): void;
|
||||
apply(
|
||||
data: number,
|
||||
len: number,
|
||||
exposureMul: number,
|
||||
contrastK: number,
|
||||
highlights: number,
|
||||
shadows: number,
|
||||
whites: number,
|
||||
blacks: number
|
||||
): void;
|
||||
}
|
||||
|
||||
let wasm: LightExports;
|
||||
|
||||
beforeAll(async () => {
|
||||
const bytes = await readFile(new URL('../../static/light.wasm', import.meta.url));
|
||||
const { instance } = await WebAssembly.instantiate(bytes, {});
|
||||
wasm = instance.exports as unknown as LightExports;
|
||||
});
|
||||
|
||||
/** Применяет правки модулем WASM к копии пикселей. */
|
||||
function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedArray {
|
||||
const points = light.curve?.rgb ?? [];
|
||||
const floats = new Float32Array(points.length * 2);
|
||||
points.forEach((p, i) => {
|
||||
floats[i * 2] = p.x;
|
||||
floats[i * 2 + 1] = p.y;
|
||||
});
|
||||
const mem = wasm.memory;
|
||||
const need = Math.ceil((4096 + pixels.length) / 65536);
|
||||
if (need > mem.buffer.byteLength / 65536) mem.grow(need - mem.buffer.byteLength / 65536);
|
||||
new Float32Array(mem.buffer, 0, floats.length).set(floats);
|
||||
wasm.build_lut(0, points.length, 0);
|
||||
wasm.build_lut(0, 0, 1);
|
||||
wasm.build_lut(0, 0, 2);
|
||||
wasm.build_lut(0, 0, 3);
|
||||
new Uint8ClampedArray(mem.buffer, 4096, pixels.length).set(pixels);
|
||||
wasm.apply(
|
||||
4096,
|
||||
pixels.length,
|
||||
Math.pow(2, light.exposure),
|
||||
1 + light.contrast / 100,
|
||||
(light.highlights / 100) * 80,
|
||||
(light.shadows / 100) * 80,
|
||||
(light.whites / 100) * 80,
|
||||
(light.blacks / 100) * 80
|
||||
);
|
||||
return new Uint8ClampedArray(mem.buffer, 4096, pixels.length).slice();
|
||||
}
|
||||
|
||||
describe('WASM-модуль фильтров', () => {
|
||||
it('совпадает с JS-реализацией', () => {
|
||||
const light: LightParams = {
|
||||
...emptyEditParams().light,
|
||||
exposure: 0.8,
|
||||
contrast: 25,
|
||||
highlights: -30,
|
||||
shadows: 40,
|
||||
whites: 10,
|
||||
blacks: -15,
|
||||
curve: { rgb: [{ x: 0, y: 0 }, { x: 0.5, y: 0.4 }, { x: 1, y: 1 }] }
|
||||
};
|
||||
const source = new Uint8ClampedArray(4 * 64);
|
||||
for (let i = 0; i < source.length; i += 4) {
|
||||
source[i] = (i * 7) % 256;
|
||||
source[i + 1] = (i * 13) % 256;
|
||||
source[i + 2] = (i * 29) % 256;
|
||||
source[i + 3] = 255;
|
||||
}
|
||||
const expected = source.slice();
|
||||
applyLightToPixels(expected, light);
|
||||
const actual = applyWasm(source.slice(), light);
|
||||
expect(Array.from(actual)).toEqual(Array.from(expected));
|
||||
});
|
||||
|
||||
it('не меняет прозрачные пиксели', () => {
|
||||
const light: LightParams = { ...emptyEditParams().light, exposure: 2 };
|
||||
const pixels = new Uint8ClampedArray([10, 20, 30, 0]);
|
||||
const out = applyWasm(pixels, light);
|
||||
expect(Array.from(out)).toEqual([10, 20, 30, 0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { applyLightToPixels, type CurvePoint, type LightParams } from './edit';
|
||||
|
||||
/**
|
||||
* WASM-модуль фильтров «Свет».
|
||||
*
|
||||
* Модуль собран из wasm/light (Rust, no_std) и лежит в static/light.wasm.
|
||||
* Он считает правки по пикселям — это горячий путь предпросмотра в
|
||||
* просмотрщике. Если модуль недоступен, используется JS-реализация.
|
||||
*/
|
||||
|
||||
const POINTS_OFFSET = 0;
|
||||
const PIXELS_OFFSET = 4096;
|
||||
const PAGE = 65536;
|
||||
|
||||
interface LightExports {
|
||||
memory: WebAssembly.Memory;
|
||||
build_lut(points: number, count: number, channel: number): void;
|
||||
apply(
|
||||
data: number,
|
||||
len: number,
|
||||
exposureMul: number,
|
||||
contrastK: number,
|
||||
highlights: number,
|
||||
shadows: number,
|
||||
whites: number,
|
||||
blacks: number
|
||||
): void;
|
||||
}
|
||||
|
||||
let exportsRef: LightExports | null = null;
|
||||
let loading: Promise<boolean> | null = null;
|
||||
|
||||
/** Загружает и инстанцирует модуль. Повторные вызовы переиспользуют его. */
|
||||
export function initLightWasm(url = '/light.wasm'): Promise<boolean> {
|
||||
if (exportsRef) return Promise.resolve(true);
|
||||
if (!loading) {
|
||||
loading = (async () => {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return false;
|
||||
const bytes = await res.arrayBuffer();
|
||||
const { instance } = await WebAssembly.instantiate(bytes, {});
|
||||
exportsRef = instance.exports as unknown as LightExports;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return loading;
|
||||
}
|
||||
|
||||
export function lightWasmReady(): boolean {
|
||||
return exportsRef !== null;
|
||||
}
|
||||
|
||||
/** Расширяет память модуля до нужного размера. */
|
||||
function ensureMemory(bytes: number): WebAssembly.Memory | null {
|
||||
const ex = exportsRef;
|
||||
if (!ex) return null;
|
||||
const mem = ex.memory;
|
||||
const need = Math.ceil(bytes / PAGE);
|
||||
const have = mem.buffer.byteLength / PAGE;
|
||||
if (need > have) mem.grow(need - have);
|
||||
return mem;
|
||||
}
|
||||
|
||||
/** Записывает точки кривой в память модуля и строит таблицу соответствия. */
|
||||
function buildLut(mem: WebAssembly.Memory, points: CurvePoint[], channel: number): void {
|
||||
const ex = exportsRef;
|
||||
if (!ex) return;
|
||||
const floats = new Float32Array(points.length * 2);
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
floats[i * 2] = points[i].x;
|
||||
floats[i * 2 + 1] = points[i].y;
|
||||
}
|
||||
new Float32Array(mem.buffer, POINTS_OFFSET, floats.length).set(floats);
|
||||
ex.build_lut(POINTS_OFFSET, points.length, channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет правки к пикселям. Возвращает false, если модуль не готов и
|
||||
* вызывающий код должен использовать JS-реализацию.
|
||||
*/
|
||||
export function applyLightWasm(data: Uint8ClampedArray, light: LightParams): boolean {
|
||||
const ex = exportsRef;
|
||||
if (!ex) return false;
|
||||
const bytes = PIXELS_OFFSET + data.length;
|
||||
const mem = ensureMemory(bytes);
|
||||
if (!mem) return false;
|
||||
|
||||
const curve = light.curve ?? {};
|
||||
buildLut(mem, curve.rgb ?? [], 0);
|
||||
buildLut(mem, curve.red ?? [], 1);
|
||||
buildLut(mem, curve.green ?? [], 2);
|
||||
buildLut(mem, curve.blue ?? [], 3);
|
||||
|
||||
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data);
|
||||
ex.apply(
|
||||
PIXELS_OFFSET,
|
||||
data.length,
|
||||
Math.pow(2, light.exposure),
|
||||
1 + light.contrast / 100,
|
||||
(light.highlights / 100) * 80,
|
||||
(light.shadows / 100) * 80,
|
||||
(light.whites / 100) * 80,
|
||||
(light.blacks / 100) * 80
|
||||
);
|
||||
data.set(new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Применяет правки: сначала WASM, при его недоступности — JS. */
|
||||
export function applyLight(data: Uint8ClampedArray, light: LightParams): void {
|
||||
if (applyLightWasm(data, light)) return;
|
||||
applyLightToPixels(data, light);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { emptyEditParams, type EditParams } from '$lib/edit';
|
||||
|
||||
/**
|
||||
* Живые параметры правок текущего фото.
|
||||
*
|
||||
* Панель редактирования пишет сюда значения ползунков, а просмотрщик берёт их,
|
||||
* чтобы показать правки в видимой области, пока сервер не отрендерил фото.
|
||||
*/
|
||||
class EditStore {
|
||||
/** Фото, к которому относятся параметры. */
|
||||
photoId = $state('');
|
||||
/** Параметры, которые пользователь меняет прямо сейчас. */
|
||||
params = $state<EditParams>(emptyEditParams());
|
||||
|
||||
set(photoId: string, params: EditParams) {
|
||||
this.photoId = photoId;
|
||||
this.params = params;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.photoId = '';
|
||||
this.params = emptyEditParams();
|
||||
}
|
||||
}
|
||||
|
||||
export const edit = new EditStore();
|
||||
Reference in New Issue
Block a user