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
+5 -3
View File
@@ -231,9 +231,11 @@
const dh = Math.round(visibleH);
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh);
const data = ctx.getImageData(dx, dy, dw, dh);
applyLight(data.data, liveParams.light);
ctx.putImageData(data, dx, dy);
// Фильтруем весь холст: так прошлый кадр не смешивается с новым.
const frame = ctx.getImageData(0, 0, width, height);
applyLight(frame.data, liveParams.light);
ctx.putImageData(frame, 0, 0);
}
function scheduleViewport() {
+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);
});
});
+48 -24
View File
@@ -1,23 +1,25 @@
import { applyLightToPixels, type CurvePoint, type LightParams } from './edit';
import { applyLightToPixels, buildCurveLUT, type LightParams } from './edit';
/**
* WASM-модуль фильтров «Свет».
*
* Модуль собран из wasm/light (Rust, no_std) и лежит в static/light.wasm.
* Он считает правки по пикселям — это горячий путь предпросмотра в
* просмотрщике. Если модуль недоступен, используется JS-реализация.
* Он попиксельно применяет правки — это горячий путь предпросмотра в
* просмотрщике. Если модуль недоступен или считает иначе, чем эталонная
* JS-реализация, используется JS.
*/
const POINTS_OFFSET = 0;
const LUTS_OFFSET = 0;
const PIXELS_OFFSET = 4096;
const PAGE = 65536;
const LUT_LEN = 256;
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,
@@ -36,7 +38,7 @@ export function initLightWasm(url = '/light.wasm'): Promise<boolean> {
if (!loading) {
loading = (async () => {
try {
const res = await fetch(url);
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) return false;
const bytes = await res.arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes, {});
@@ -67,17 +69,18 @@ function ensureMemory(bytes: number): WebAssembly.Memory | null {
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);
/** Готовит четыре таблицы соответствия: общая, красный, зелёный, синий. */
function writeLuts(mem: WebAssembly.Memory, light: LightParams): void {
const curve = light.curve ?? {};
const tables = [
buildCurveLUT(curve.rgb),
buildCurveLUT(curve.red),
buildCurveLUT(curve.green),
buildCurveLUT(curve.blue)
];
const bytes = new Uint8Array(LUT_LEN * tables.length);
tables.forEach((table, i) => bytes.set(table, i * LUT_LEN));
new Uint8Array(mem.buffer, LUTS_OFFSET, bytes.length).set(bytes);
}
/**
@@ -91,16 +94,12 @@ export function applyLightWasm(data: Uint8ClampedArray, light: LightParams): boo
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);
writeLuts(mem, light);
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data);
ex.apply(
PIXELS_OFFSET,
data.length,
LUTS_OFFSET,
Math.pow(2, light.exposure),
1 + light.contrast / 100,
(light.highlights / 100) * 80,
@@ -112,8 +111,33 @@ export function applyLightWasm(data: Uint8ClampedArray, light: LightParams): boo
return true;
}
/**
* Сверяет модуль с эталонной JS-реализацией на маленьком образце.
*
* Модуль и JS должны считать одинаково; если модуль расходится (другая
* сборка, битая версия в кэше) — доверяем JS, чтобы предпросмотр не был
* испорчен.
*/
function wasmMatchesReference(light: LightParams): boolean {
const probe = new Uint8ClampedArray(16 * 4);
for (let i = 0; i < probe.length; i += 4) {
probe[i] = (i * 7) % 256;
probe[i + 1] = (i * 13) % 256;
probe[i + 2] = (i * 29) % 256;
probe[i + 3] = 255;
}
const expected = new Uint8ClampedArray(probe);
applyLightToPixels(expected, light);
const actual = new Uint8ClampedArray(probe);
if (!applyLightWasm(actual, light)) return false;
for (let i = 0; i < expected.length; i++) {
if (expected[i] !== actual[i]) return false;
}
return true;
}
/** Применяет правки: сначала WASM, при его недоступности — JS. */
export function applyLight(data: Uint8ClampedArray, light: LightParams): void {
if (applyLightWasm(data, light)) return;
if (wasmMatchesReference(light) && applyLightWasm(data, light)) return;
applyLightToPixels(data, light);
}
+12
View File
@@ -156,6 +156,18 @@ async function handleNavigation(request: Request): Promise<Response> {
/** Статика приложения: сначала кеш, затем сеть с дозаписью. */
async function handleAsset(request: Request): Promise<Response> {
const cache = await caches.open(SHELL_CACHE);
// wasm-модуль фильтров меняется вместе с ними: старый кеш сломал бы
// предпросмотр правок, поэтому его всегда берём из сети.
if (new URL(request.url).pathname.endsWith('.wasm')) {
try {
const fresh = await fetch(request, { cache: 'no-store' });
if (fresh.ok) await cache.put(request, fresh.clone());
return fresh;
} catch {
const stale = await cache.match(request);
if (stale) return stale;
}
}
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);