feat(photos): инструмент «Цвет» с WebGL-превью и пипеткой

- неразрушающие правки цвета: баланс белого, вибрация/насыщенность,
  цветовой микшер, колористика и поканальная коррекция
- серверный рендер цвета в Go и паритетная реализация в WASM
- живое превью на WebGL2 (кадр считает GPU), CPU/WASM-фолбэк
- пипетка баланса белого по нейтральной точке
- новые контролы: слайдеры с градиентами, цветовые колёса,
  двойной клик по контролу сбрасывает значение
- тесты: параметры, рендер, паритет JS/WASM, e2e сохранения и сброса
This commit is contained in:
iu
2026-10-23 21:57:48 +03:00
parent c886c6f22c
commit d791b9a572
19 changed files with 3375 additions and 144 deletions
+269
View File
@@ -108,3 +108,272 @@ fn adjust(
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;
}
}