- неразрушающие правки цвета: баланс белого, вибрация/насыщенность, цветовой микшер, колористика и поканальная коррекция - серверный рендер цвета в Go и паритетная реализация в WASM - живое превью на WebGL2 (кадр считает GPU), CPU/WASM-фолбэк - пипетка баланса белого по нейтральной точке - новые контролы: слайдеры с градиентами, цветовые колёса, двойной клик по контролу сбрасывает значение - тесты: параметры, рендер, паритет JS/WASM, e2e сохранения и сброса
380 lines
12 KiB
Rust
380 lines
12 KiB
Rust
//! Фильтры «Свет» для веб-интерфейса: попиксельная обработка кадра.
|
|
//!
|
|
//! Модуль собирается в WebAssembly (`wasm32-unknown-unknown`) и загружается
|
|
//! браузером, чтобы предпросмотр правок в просмотрщике не ждал серверного
|
|
//! рендера.
|
|
//!
|
|
//! Таблицы тональных кривых считает вызывающий код (JavaScript) и передаёт
|
|
//! сюда готовыми: модуль не хранит состояние и не использует transcendental
|
|
//! функции, поэтому работает без математической библиотеки.
|
|
#![no_std]
|
|
|
|
use core::panic::PanicInfo;
|
|
|
|
#[panic_handler]
|
|
fn panic(_info: &PanicInfo) -> ! {
|
|
loop {}
|
|
}
|
|
|
|
/// Количество каналов: 0 — общая кривая, 1 — красный, 2 — зелёный, 3 — синий.
|
|
const CHANNELS: usize = 4;
|
|
|
|
/// Длина таблицы соответствия одного канала.
|
|
const LUT_LEN: usize = 256;
|
|
|
|
#[inline(always)]
|
|
fn clamp01(v: f32) -> f32 {
|
|
if v < 0.0 {
|
|
0.0
|
|
} else if v > 1.0 {
|
|
1.0
|
|
} else {
|
|
v
|
|
}
|
|
}
|
|
|
|
#[inline(always)]
|
|
fn clampi(v: i32, lo: i32, hi: i32) -> i32 {
|
|
if v < lo {
|
|
lo
|
|
} else if v > hi {
|
|
hi
|
|
} else {
|
|
v
|
|
}
|
|
}
|
|
|
|
/// Применяет правки «Свет» к пикселям RGBA в памяти.
|
|
///
|
|
/// `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,
|
|
shadows: f32,
|
|
whites: f32,
|
|
blacks: f32,
|
|
) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// Обрабатывает один канал: экспозиция, контраст, света, тени, белые, чёрные
|
|
/// и обе тональные кривые (общая, затем канальная).
|
|
#[inline(always)]
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn adjust(
|
|
value: f32,
|
|
exposure_mul: f32,
|
|
contrast_k: f32,
|
|
highlights: f32,
|
|
shadows: f32,
|
|
whites: f32,
|
|
blacks: f32,
|
|
master: &[u8],
|
|
channel: &[u8],
|
|
) -> u8 {
|
|
let mut v = value * exposure_mul;
|
|
v = 128.0 + (v - 128.0) * contrast_k;
|
|
let mut w = clamp01((v - 128.0) / 127.0);
|
|
v += highlights * w * w;
|
|
w = clamp01((128.0 - v) / 128.0);
|
|
v += shadows * w * w;
|
|
w = clamp01((v - 192.0) / 63.0);
|
|
v += whites * w;
|
|
w = clamp01((64.0 - v) / 64.0);
|
|
v += blacks * w;
|
|
let idx = clampi((v + 0.5) as i32, 0, 255) as usize;
|
|
let v = master[idx] as f32;
|
|
let idx = clampi((v + 0.5) as i32, 0, 255) as usize;
|
|
channel[idx]
|
|
}
|
|
|
|
/// Количество f64-параметров цвета.
|
|
const COLOR_PARAM_COUNT: usize = 42;
|
|
|
|
/// Центры полос цветового микшера в градусах.
|
|
const BAND_CENTERS: [f64; 8] = [0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 280.0, 320.0];
|
|
|
|
#[inline(always)]
|
|
fn clampf(v: f64, lo: f64, hi: f64) -> f64 {
|
|
if v < lo {
|
|
lo
|
|
} else if v > hi {
|
|
hi
|
|
} else {
|
|
v
|
|
}
|
|
}
|
|
|
|
#[inline(always)]
|
|
fn clamp01d(v: f64) -> f64 {
|
|
clampf(v, 0.0, 1.0)
|
|
}
|
|
|
|
#[inline(always)]
|
|
fn to_u8(v: f64) -> u8 {
|
|
(clampf(v, 0.0, 255.0) + 0.5) as u8
|
|
}
|
|
|
|
/// RGB (0..255) -> HSL: оттенок в градусах, насыщенность и яркость в 0..1.
|
|
fn rgb_to_hsl(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
|
|
let r = r / 255.0;
|
|
let g = g / 255.0;
|
|
let b = b / 255.0;
|
|
let max = if r > g { if r > b { r } else { b } } else if g > b { g } else { b };
|
|
let min = if r < g { if r < b { r } else { b } } else if g < b { g } else { b };
|
|
let l = (max + min) / 2.0;
|
|
if max == min {
|
|
return (0.0, 0.0, l);
|
|
}
|
|
let d = max - min;
|
|
let s = if l > 0.5 {
|
|
d / (2.0 - max - min)
|
|
} else {
|
|
d / (max + min)
|
|
};
|
|
let mut h;
|
|
if max == r {
|
|
h = (g - b) / d;
|
|
if g < b {
|
|
h += 6.0;
|
|
}
|
|
} else if max == g {
|
|
h = (b - r) / d + 2.0;
|
|
} else {
|
|
h = (r - g) / d + 4.0;
|
|
}
|
|
(h * 60.0, s, l)
|
|
}
|
|
|
|
fn hue_to_rgb(p: f64, q: f64, mut t: f64) -> f64 {
|
|
if t < 0.0 {
|
|
t += 1.0;
|
|
}
|
|
if t > 1.0 {
|
|
t -= 1.0;
|
|
}
|
|
if t < 1.0 / 6.0 {
|
|
p + (q - p) * 6.0 * t
|
|
} else if t < 1.0 / 2.0 {
|
|
q
|
|
} else if t < 2.0 / 3.0 {
|
|
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
|
} else {
|
|
p
|
|
}
|
|
}
|
|
|
|
/// HSL -> RGB (0..255).
|
|
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
|
|
if s == 0.0 {
|
|
let v = l * 255.0;
|
|
return (v, v, v);
|
|
}
|
|
let q = if l < 0.5 {
|
|
l * (1.0 + s)
|
|
} else {
|
|
l + s - l * s
|
|
};
|
|
let p = 2.0 * l - q;
|
|
let hk = h / 360.0;
|
|
(
|
|
hue_to_rgb(p, q, hk + 1.0 / 3.0) * 255.0,
|
|
hue_to_rgb(p, q, hk) * 255.0,
|
|
hue_to_rgb(p, q, hk - 1.0 / 3.0) * 255.0,
|
|
)
|
|
}
|
|
|
|
fn band_weight(h: f64, center: f64) -> f64 {
|
|
let mut d = h - center;
|
|
if d < 0.0 {
|
|
d = -d;
|
|
}
|
|
if d > 180.0 {
|
|
d = 360.0 - d;
|
|
}
|
|
clamp01d(1.0 - d / 60.0)
|
|
}
|
|
|
|
/// Применяет правки «Цвет» к пикселям RGBA в памяти.
|
|
///
|
|
/// `params` — блок из `COLOR_PARAM_COUNT` значений f64, подготовленный
|
|
/// вызывающим кодом. Порядок операций повторяет серверный рендер и JS.
|
|
#[no_mangle]
|
|
pub extern "C" fn apply_color(data: *mut u8, len: u32, params: *const f64) {
|
|
let p = unsafe { core::slice::from_raw_parts(params, COLOR_PARAM_COUNT) };
|
|
let temperature = p[0];
|
|
let tint = p[1];
|
|
let vibrance = p[2];
|
|
let saturation = p[3];
|
|
let blending = p[37];
|
|
let balance = p[38];
|
|
let blend = blending;
|
|
|
|
// Микшер часто пуст (правят только колёса) — тогда не тратим на него время.
|
|
let mut has_mixer = false;
|
|
let mut band = 0usize;
|
|
while band < 8 {
|
|
let base = 4 + band * 3;
|
|
if p[base] != 0.0 || p[base + 1] != 0.0 || p[base + 2] != 0.0 {
|
|
has_mixer = true;
|
|
break;
|
|
}
|
|
band += 1;
|
|
}
|
|
|
|
let mut tints = [(0.0f64, 0.0f64, 0.0f64); 3];
|
|
let mut w = 0usize;
|
|
while w < 3 {
|
|
tints[w] = hsl_to_rgb(p[28 + w * 2], 1.0, 0.5);
|
|
w += 1;
|
|
}
|
|
|
|
let buf = unsafe { core::slice::from_raw_parts_mut(data, len as usize) };
|
|
let mut i = 0usize;
|
|
while i + 3 < buf.len() {
|
|
let alpha = unsafe { *buf.get_unchecked(i + 3) };
|
|
if alpha != 0 {
|
|
let mut r = unsafe { *buf.get_unchecked(i) } as f64;
|
|
let mut g = unsafe { *buf.get_unchecked(i + 1) } as f64;
|
|
let mut b = unsafe { *buf.get_unchecked(i + 2) } as f64;
|
|
|
|
// Баланс белого.
|
|
r *= 1.0 + 0.2 * temperature + 0.1 * tint;
|
|
g *= 1.0 - 0.2 * tint;
|
|
b *= 1.0 - 0.2 * temperature + 0.1 * tint;
|
|
r = clampf(r, 0.0, 255.0);
|
|
g = clampf(g, 0.0, 255.0);
|
|
b = clampf(b, 0.0, 255.0);
|
|
|
|
// Быстрый путь: только колёса/яркость, без микшера и насыщенности.
|
|
// Тогда HSL-переход не нужен — lightness считается напрямую.
|
|
let fast = !has_mixer && saturation == 0.0 && vibrance == 0.0;
|
|
let weights: [f64; 3];
|
|
let mut rr: f64;
|
|
let mut gg: f64;
|
|
let mut bb: f64;
|
|
|
|
if fast {
|
|
let max = if r > g { if r > b { r } else { b } } else if g > b { g } else { b };
|
|
let min = if r < g { if r < b { r } else { b } } else if g < b { g } else { b };
|
|
let l = (max + min) / 510.0;
|
|
let lb = clamp01d(l + balance * 0.5);
|
|
let shadow_w = {
|
|
let t = (1.0 - lb) * (1.0 - lb);
|
|
t * t
|
|
};
|
|
let highlight_w = {
|
|
let t = lb * lb;
|
|
t * t
|
|
};
|
|
weights = [shadow_w, 1.0 - shadow_w - highlight_w, highlight_w];
|
|
|
|
let mut target_l = l;
|
|
let mut wheel = 0usize;
|
|
while wheel < 3 {
|
|
target_l += p[34 + wheel] * 0.2 * weights[wheel];
|
|
wheel += 1;
|
|
}
|
|
target_l = clamp01d(target_l);
|
|
let delta = (target_l - l) * 255.0;
|
|
rr = r + delta;
|
|
gg = g + delta;
|
|
bb = b + delta;
|
|
} else {
|
|
// RGB -> HSL.
|
|
let (mut h, mut s, mut l) = rgb_to_hsl(r, g, b);
|
|
|
|
// Насыщенность и вибрация.
|
|
if saturation != 0.0 || vibrance != 0.0 {
|
|
s = clamp01d(s * (1.0 + saturation + vibrance * (1.0 - s)));
|
|
}
|
|
|
|
// Цветовой микшер.
|
|
if has_mixer {
|
|
let mut band = 0usize;
|
|
while band < 8 {
|
|
let base = 4 + band * 3;
|
|
let weight = band_weight(h, BAND_CENTERS[band]);
|
|
if weight != 0.0 {
|
|
h += p[base] * 30.0 * weight;
|
|
s = clamp01d(s * (1.0 + p[base + 1] * weight));
|
|
l = clamp01d(l + p[base + 2] * 0.2 * weight);
|
|
}
|
|
band += 1;
|
|
}
|
|
}
|
|
h = h % 360.0;
|
|
if h < 0.0 {
|
|
h += 360.0;
|
|
}
|
|
|
|
let lb = clamp01d(l + balance * 0.5);
|
|
let shadow_w = {
|
|
let t = (1.0 - lb) * (1.0 - lb);
|
|
t * t
|
|
};
|
|
let highlight_w = {
|
|
let t = lb * lb;
|
|
t * t
|
|
};
|
|
weights = [shadow_w, 1.0 - shadow_w - highlight_w, highlight_w];
|
|
let mut wheel = 0usize;
|
|
while wheel < 3 {
|
|
l = clamp01d(l + p[34 + wheel] * 0.2 * weights[wheel]);
|
|
wheel += 1;
|
|
}
|
|
|
|
let (r2, g2, b2) = hsl_to_rgb(h, s, l);
|
|
rr = r2;
|
|
gg = g2;
|
|
bb = b2;
|
|
}
|
|
|
|
// Точечный цвет: три колеса по зонам яркости.
|
|
let mut wheel = 0usize;
|
|
while wheel < 3 {
|
|
let strength = p[28 + wheel * 2 + 1] * 0.5 * blend * weights[wheel];
|
|
if strength != 0.0 {
|
|
let t = tints[wheel];
|
|
rr = rr * (1.0 - strength) + t.0 * strength;
|
|
gg = gg * (1.0 - strength) + t.1 * strength;
|
|
bb = bb * (1.0 - strength) + t.2 * strength;
|
|
}
|
|
wheel += 1;
|
|
}
|
|
|
|
// Цветокоррекция.
|
|
rr *= 1.0 + p[39] * 0.5;
|
|
gg *= 1.0 + p[40] * 0.5;
|
|
bb *= 1.0 + p[41] * 0.5;
|
|
|
|
unsafe {
|
|
*buf.get_unchecked_mut(i) = to_u8(rr);
|
|
*buf.get_unchecked_mut(i + 1) = to_u8(gg);
|
|
*buf.get_unchecked_mut(i + 2) = to_u8(bb);
|
|
} }
|
|
i += 4;
|
|
}
|
|
}
|