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); const dh = Math.round(visibleH);
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh); 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() { function scheduleViewport() {
+30 -18
View File
@@ -1,13 +1,13 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { beforeAll, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
import { applyLightToPixels, emptyEditParams, type LightParams } from './edit'; import { applyLightToPixels, buildCurveLUT, emptyEditParams, type LightParams } from './edit';
interface LightExports { interface LightExports {
memory: WebAssembly.Memory; memory: WebAssembly.Memory;
build_lut(points: number, count: number, channel: number): void;
apply( apply(
data: number, data: number,
len: number, len: number,
luts: number,
exposureMul: number, exposureMul: number,
contrastK: number, contrastK: number,
highlights: number, highlights: number,
@@ -19,6 +19,9 @@ interface LightExports {
let wasm: LightExports; let wasm: LightExports;
const LUTS_OFFSET = 0;
const PIXELS_OFFSET = 4096;
beforeAll(async () => { beforeAll(async () => {
const bytes = await readFile(new URL('../../static/light.wasm', import.meta.url)); const bytes = await readFile(new URL('../../static/light.wasm', import.meta.url));
const { instance } = await WebAssembly.instantiate(bytes, {}); const { instance } = await WebAssembly.instantiate(bytes, {});
@@ -27,24 +30,27 @@ beforeAll(async () => {
/** Применяет правки модулем WASM к копии пикселей. */ /** Применяет правки модулем WASM к копии пикселей. */
function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedArray { 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 mem = wasm.memory;
const need = Math.ceil((4096 + pixels.length) / 65536); const need = Math.ceil((PIXELS_OFFSET + pixels.length) / 65536);
if (need > mem.buffer.byteLength / 65536) mem.grow(need - mem.buffer.byteLength / 65536); const have = mem.buffer.byteLength / 65536;
new Float32Array(mem.buffer, 0, floats.length).set(floats); if (need > have) mem.grow(need - have);
wasm.build_lut(0, points.length, 0);
wasm.build_lut(0, 0, 1); const curve = light.curve ?? {};
wasm.build_lut(0, 0, 2); const tables = [
wasm.build_lut(0, 0, 3); buildCurveLUT(curve.rgb),
new Uint8ClampedArray(mem.buffer, 4096, pixels.length).set(pixels); 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( wasm.apply(
4096, PIXELS_OFFSET,
pixels.length, pixels.length,
LUTS_OFFSET,
Math.pow(2, light.exposure), Math.pow(2, light.exposure),
1 + light.contrast / 100, 1 + light.contrast / 100,
(light.highlights / 100) * 80, (light.highlights / 100) * 80,
@@ -52,7 +58,7 @@ function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedA
(light.whites / 100) * 80, (light.whites / 100) * 80,
(light.blacks / 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-модуль фильтров', () => { describe('WASM-модуль фильтров', () => {
@@ -86,4 +92,10 @@ describe('WASM-модуль фильтров', () => {
const out = applyWasm(pixels, light); const out = applyWasm(pixels, light);
expect(Array.from(out)).toEqual([10, 20, 30, 0]); 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-модуль фильтров «Свет».
* *
* Модуль собран из wasm/light (Rust, no_std) и лежит в static/light.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 PIXELS_OFFSET = 4096;
const PAGE = 65536; const PAGE = 65536;
const LUT_LEN = 256;
interface LightExports { interface LightExports {
memory: WebAssembly.Memory; memory: WebAssembly.Memory;
build_lut(points: number, count: number, channel: number): void;
apply( apply(
data: number, data: number,
len: number, len: number,
luts: number,
exposureMul: number, exposureMul: number,
contrastK: number, contrastK: number,
highlights: number, highlights: number,
@@ -36,7 +38,7 @@ export function initLightWasm(url = '/light.wasm'): Promise<boolean> {
if (!loading) { if (!loading) {
loading = (async () => { loading = (async () => {
try { try {
const res = await fetch(url); const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) return false; if (!res.ok) return false;
const bytes = await res.arrayBuffer(); const bytes = await res.arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes, {}); const { instance } = await WebAssembly.instantiate(bytes, {});
@@ -67,17 +69,18 @@ function ensureMemory(bytes: number): WebAssembly.Memory | null {
return mem; return mem;
} }
/** Записывает точки кривой в память модуля и строит таблицу соответствия. */ /** Готовит четыре таблицы соответствия: общая, красный, зелёный, синий. */
function buildLut(mem: WebAssembly.Memory, points: CurvePoint[], channel: number): void { function writeLuts(mem: WebAssembly.Memory, light: LightParams): void {
const ex = exportsRef; const curve = light.curve ?? {};
if (!ex) return; const tables = [
const floats = new Float32Array(points.length * 2); buildCurveLUT(curve.rgb),
for (let i = 0; i < points.length; i++) { buildCurveLUT(curve.red),
floats[i * 2] = points[i].x; buildCurveLUT(curve.green),
floats[i * 2 + 1] = points[i].y; buildCurveLUT(curve.blue)
} ];
new Float32Array(mem.buffer, POINTS_OFFSET, floats.length).set(floats); const bytes = new Uint8Array(LUT_LEN * tables.length);
ex.build_lut(POINTS_OFFSET, points.length, channel); 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); const mem = ensureMemory(bytes);
if (!mem) return false; if (!mem) return false;
const curve = light.curve ?? {}; writeLuts(mem, light);
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); new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data);
ex.apply( ex.apply(
PIXELS_OFFSET, PIXELS_OFFSET,
data.length, data.length,
LUTS_OFFSET,
Math.pow(2, light.exposure), Math.pow(2, light.exposure),
1 + light.contrast / 100, 1 + light.contrast / 100,
(light.highlights / 100) * 80, (light.highlights / 100) * 80,
@@ -112,8 +111,33 @@ export function applyLightWasm(data: Uint8ClampedArray, light: LightParams): boo
return true; 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. */ /** Применяет правки: сначала WASM, при его недоступности — JS. */
export function applyLight(data: Uint8ClampedArray, light: LightParams): void { export function applyLight(data: Uint8ClampedArray, light: LightParams): void {
if (applyLightWasm(data, light)) return; if (wasmMatchesReference(light) && applyLightWasm(data, light)) return;
applyLightToPixels(data, light); applyLightToPixels(data, light);
} }
+12
View File
@@ -156,6 +156,18 @@ async function handleNavigation(request: Request): Promise<Response> {
/** Статика приложения: сначала кеш, затем сеть с дозаписью. */ /** Статика приложения: сначала кеш, затем сеть с дозаписью. */
async function handleAsset(request: Request): Promise<Response> { async function handleAsset(request: Request): Promise<Response> {
const cache = await caches.open(SHELL_CACHE); 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); const cached = await cache.match(request);
if (cached) return cached; if (cached) return cached;
const response = await fetch(request); const response = await fetch(request);
Binary file not shown.
+27 -95
View File
@@ -1,8 +1,12 @@
//! Фильтры «Свет» для веб-интерфейса: сборка кривых и попиксельная обработка. //! Фильтры «Свет» для веб-интерфейса: попиксельная обработка кадра.
//! //!
//! Модуль собирается в WebAssembly (`wasm32-unknown-unknown`) и загружается //! Модуль собирается в WebAssembly (`wasm32-unknown-unknown`) и загружается
//! браузером, чтобы предпросмотр правок в просмотрщике не ждал серверного //! браузером, чтобы предпросмотр правок в просмотрщике не ждал серверного
//! рендера. Математика совпадает с серверной (backend/internal/photos/edit.go). //! рендера.
//!
//! Таблицы тональных кривых считает вызывающий код (JavaScript) и передаёт
//! сюда готовыми: модуль не хранит состояние и не использует transcendental
//! функции, поэтому работает без математической библиотеки.
#![no_std] #![no_std]
use core::panic::PanicInfo; use core::panic::PanicInfo;
@@ -12,10 +16,11 @@ fn panic(_info: &PanicInfo) -> ! {
loop {} loop {}
} }
/// Каналы: 0 — общая кривая, 1 — красный, 2 — зелёный, 3 — синий. /// Количество каналов: 0 — общая кривая, 1 — красный, 2 — зелёный, 3 — синий.
const CHANNELS: usize = 4; const CHANNELS: usize = 4;
static mut LUTS: [[u8; 256]; CHANNELS] = [[0; 256]; CHANNELS]; /// Длина таблицы соответствия одного канала.
const LUT_LEN: usize = 256;
#[inline(always)] #[inline(always)]
fn clamp01(v: f32) -> f32 { fn clamp01(v: f32) -> f32 {
@@ -39,57 +44,16 @@ fn clampi(v: i32, lo: i32, hi: i32) -> i32 {
} }
} }
/// Строит таблицу соответствия 0..255 по точкам кривой.
///
/// `points` — пары (x, y) в диапазоне 0..1, отсортированные по x.
/// Точки вне диапазона обрезаются, дубликаты по x пропускаются.
#[no_mangle]
pub extern "C" fn build_lut(points: *const f32, count: u32, channel: u32) {
unsafe {
let luts = &mut *core::ptr::addr_of_mut!(LUTS);
let ch = (channel as usize).min(CHANNELS - 1);
let lut = &mut luts[ch];
if count < 2 {
for (i, slot) in lut.iter_mut().enumerate() {
*slot = i as u8;
}
return;
}
let n = count as usize;
let mut seg = 0usize;
for v in 0..256 {
let x = v as f32 / 255.0;
while seg + 1 < n && x > *points.add((seg + 1) * 2) {
seg += 1;
}
let x0 = *points.add(seg * 2);
let y0 = *points.add(seg * 2 + 1);
let x1 = *points.add((seg + 1) * 2);
let y1 = *points.add((seg + 1) * 2 + 1);
let y = if x <= x0 {
y0
} else if x >= x1 {
y1
} else if x1 - x0 < 1e-6 {
y1
} else {
let t = (x - x0) / (x1 - x0);
y0 + t * (y1 - y0)
};
lut[v] = (clamp01(y) * 255.0 + 0.5) as u8;
}
}
}
/// Применяет правки «Свет» к пикселям RGBA в памяти. /// Применяет правки «Свет» к пикселям RGBA в памяти.
/// ///
/// Коэффициенты вычисляет вызывающий код: модуль не использует transcendental /// `luts` — четыре таблицы соответствия по 256 байт (общая, красный, зелёный,
/// функции, поэтому работает без математической библиотеки. /// синий), подготовленные вызывающим кодом.
#[no_mangle] #[no_mangle]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub extern "C" fn apply( pub extern "C" fn apply(
data: *mut u8, data: *mut u8,
len: u32, len: u32,
luts: *const u8,
exposure_mul: f32, exposure_mul: f32,
contrast_k: f32, contrast_k: f32,
highlights: f32, highlights: f32,
@@ -97,52 +61,20 @@ pub extern "C" fn apply(
whites: f32, whites: f32,
blacks: f32, blacks: f32,
) { ) {
unsafe { let tables = unsafe { core::slice::from_raw_parts(luts, CHANNELS * LUT_LEN) };
let luts = &*core::ptr::addr_of!(LUTS); let master = &tables[0..LUT_LEN];
let master = &luts[0]; let red = &tables[LUT_LEN..2 * LUT_LEN];
let red = &luts[1]; let green = &tables[2 * LUT_LEN..3 * LUT_LEN];
let green = &luts[2]; let blue = &tables[3 * LUT_LEN..4 * LUT_LEN];
let blue = &luts[3]; let buf = unsafe { core::slice::from_raw_parts_mut(data, len as usize) };
let buf = core::slice::from_raw_parts_mut(data, len as usize); let mut i = 0usize;
let mut i = 0usize; while i + 3 < buf.len() {
while i + 3 < buf.len() { if buf[i + 3] != 0 {
if buf[i + 3] != 0 { buf[i] = adjust(buf[i] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, red);
buf[i] = adjust( buf[i + 1] = adjust(buf[i + 1] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, green);
buf[i] as f32, buf[i + 2] = adjust(buf[i + 2] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, blue);
exposure_mul,
contrast_k,
highlights,
shadows,
whites,
blacks,
master,
red,
);
buf[i + 1] = adjust(
buf[i + 1] as f32,
exposure_mul,
contrast_k,
highlights,
shadows,
whites,
blacks,
master,
green,
);
buf[i + 2] = adjust(
buf[i + 2] as f32,
exposure_mul,
contrast_k,
highlights,
shadows,
whites,
blacks,
master,
blue,
);
}
i += 4;
} }
i += 4;
} }
} }
@@ -158,8 +90,8 @@ fn adjust(
shadows: f32, shadows: f32,
whites: f32, whites: f32,
blacks: f32, blacks: f32,
master: &[u8; 256], master: &[u8],
channel: &[u8; 256], channel: &[u8],
) -> u8 { ) -> u8 {
let mut v = value * exposure_mul; let mut v = value * exposure_mul;
v = 128.0 + (v - 128.0) * contrast_k; v = 128.0 + (v - 128.0) * contrast_k;
+1 -1
View File
@@ -802,7 +802,7 @@ SHALL очищаться. При отсутствии сети приложен
#### Scenario: Фильтры считаются в браузере #### Scenario: Фильтры считаются в браузере
- **WHEN** браузер рисует предпросмотр правок - **WHEN** браузер рисует предпросмотр правок
- **THEN** фильтры применяются WASM-модулем, а при его недоступности — JavaScript-реализацией с тем же результатом - **THEN** фильтры применяются WASM-модулем, а если модул недоступен или считает иначе, чем эталонная JS-реализация, — JS-реализацией с тем же результатом
#### Scenario: Сброс правок #### Scenario: Сброс правок
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование» - **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»