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
+27 -95
View File
@@ -1,8 +1,12 @@
//! Фильтры «Свет» для веб-интерфейса: сборка кривых и попиксельная обработка.
//! Фильтры «Свет» для веб-интерфейса: попиксельная обработка кадра.
//!
//! Модуль собирается в WebAssembly (`wasm32-unknown-unknown`) и загружается
//! браузером, чтобы предпросмотр правок в просмотрщике не ждал серверного
//! рендера. Математика совпадает с серверной (backend/internal/photos/edit.go).
//! рендера.
//!
//! Таблицы тональных кривых считает вызывающий код (JavaScript) и передаёт
//! сюда готовыми: модуль не хранит состояние и не использует transcendental
//! функции, поэтому работает без математической библиотеки.
#![no_std]
use core::panic::PanicInfo;
@@ -12,10 +16,11 @@ fn panic(_info: &PanicInfo) -> ! {
loop {}
}
/// Каналы: 0 — общая кривая, 1 — красный, 2 — зелёный, 3 — синий.
/// Количество каналов: 0 — общая кривая, 1 — красный, 2 — зелёный, 3 — синий.
const CHANNELS: usize = 4;
static mut LUTS: [[u8; 256]; CHANNELS] = [[0; 256]; CHANNELS];
/// Длина таблицы соответствия одного канала.
const LUT_LEN: usize = 256;
#[inline(always)]
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 в памяти.
///
/// Коэффициенты вычисляет вызывающий код: модуль не использует transcendental
/// функции, поэтому работает без математической библиотеки.
/// `luts` — четыре таблицы соответствия по 256 байт (общая, красный, зелёный,
/// синий), подготовленные вызывающим кодом.
#[no_mangle]
#[allow(clippy::too_many_arguments)]
pub extern "C" fn apply(
data: *mut u8,
len: u32,
luts: *const u8,
exposure_mul: f32,
contrast_k: f32,
highlights: f32,
@@ -97,52 +61,20 @@ pub extern "C" fn apply(
whites: f32,
blacks: f32,
) {
unsafe {
let luts = &*core::ptr::addr_of!(LUTS);
let master = &luts[0];
let red = &luts[1];
let green = &luts[2];
let blue = &luts[3];
let buf = core::slice::from_raw_parts_mut(data, len as usize);
let mut i = 0usize;
while i + 3 < buf.len() {
if buf[i + 3] != 0 {
buf[i] = adjust(
buf[i] as f32,
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;
let tables = unsafe { core::slice::from_raw_parts(luts, CHANNELS * LUT_LEN) };
let master = &tables[0..LUT_LEN];
let red = &tables[LUT_LEN..2 * LUT_LEN];
let green = &tables[2 * LUT_LEN..3 * LUT_LEN];
let blue = &tables[3 * LUT_LEN..4 * LUT_LEN];
let buf = unsafe { core::slice::from_raw_parts_mut(data, len as usize) };
let mut i = 0usize;
while i + 3 < buf.len() {
if buf[i + 3] != 0 {
buf[i] = adjust(buf[i] as f32, 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;
}
}
@@ -158,8 +90,8 @@ fn adjust(
shadows: f32,
whites: f32,
blacks: f32,
master: &[u8; 256],
channel: &[u8; 256],
master: &[u8],
channel: &[u8],
) -> u8 {
let mut v = value * exposure_mul;
v = 128.0 + (v - 128.0) * contrast_k;