fix(web): искажение цветов в предпросмотре правок

Таблицы тональных кривых в WASM-модуле лежали в статическом массиве, который
линковщик помещал в сегмент .data — такой сегмент в wasm доступен только для
чтения, поэтому запись в него не срабатывала, и модуль считал по мусорным
данным (отсюда «каша» вместо плавного градиента).

Теперь таблицы считает и передаёт JS: модуль не хранит состояния и только
применяет правки к пикселям. Дополнительно клиент сверяет модуль с эталонной
JS-реализацией на маленьком образце и, если они расходятся, доверяет JS.

Service worker больше не кэширует .wasm: старый модуль в кэше ломал
предпросмотр даже после обновления сборки.
This commit is contained in:
iu
2026-10-21 01:19:45 +03:00
parent f8ee93777d
commit 2668d87347
7 changed files with 123 additions and 141 deletions
+30 -18
View File
@@ -1,13 +1,13 @@
import { readFile } from 'node:fs/promises';
import { beforeAll, describe, expect, it } from 'vitest';
import { applyLightToPixels, emptyEditParams, type LightParams } from './edit';
import { applyLightToPixels, buildCurveLUT, emptyEditParams, type LightParams } from './edit';
interface LightExports {
memory: WebAssembly.Memory;
build_lut(points: number, count: number, channel: number): void;
apply(
data: number,
len: number,
luts: number,
exposureMul: number,
contrastK: number,
highlights: number,
@@ -19,6 +19,9 @@ interface LightExports {
let wasm: LightExports;
const LUTS_OFFSET = 0;
const PIXELS_OFFSET = 4096;
beforeAll(async () => {
const bytes = await readFile(new URL('../../static/light.wasm', import.meta.url));
const { instance } = await WebAssembly.instantiate(bytes, {});
@@ -27,24 +30,27 @@ beforeAll(async () => {
/** Применяет правки модулем 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);
const need = Math.ceil((PIXELS_OFFSET + pixels.length) / 65536);
const have = mem.buffer.byteLength / 65536;
if (need > have) mem.grow(need - have);
const curve = light.curve ?? {};
const tables = [
buildCurveLUT(curve.rgb),
buildCurveLUT(curve.red),
buildCurveLUT(curve.green),
buildCurveLUT(curve.blue)
];
const luts = new Uint8Array(256 * tables.length);
tables.forEach((table, i) => luts.set(table, i * 256));
new Uint8Array(mem.buffer, LUTS_OFFSET, luts.length).set(luts);
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).set(pixels);
wasm.apply(
4096,
PIXELS_OFFSET,
pixels.length,
LUTS_OFFSET,
Math.pow(2, light.exposure),
1 + light.contrast / 100,
(light.highlights / 100) * 80,
@@ -52,7 +58,7 @@ function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedA
(light.whites / 100) * 80,
(light.blacks / 100) * 80
);
return new Uint8ClampedArray(mem.buffer, 4096, pixels.length).slice();
return new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).slice();
}
describe('WASM-модуль фильтров', () => {
@@ -86,4 +92,10 @@ describe('WASM-модуль фильтров', () => {
const out = applyWasm(pixels, light);
expect(Array.from(out)).toEqual([10, 20, 30, 0]);
});
it('осветляет при положительной экспозиции', () => {
const light: LightParams = { ...emptyEditParams().light, exposure: 1 };
const out = applyWasm(new Uint8ClampedArray([100, 100, 100, 255]), light);
expect(out[0]).toBeGreaterThan(100);
});
});