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
+89
View File
@@ -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]);
});
});