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
+375 -1
View File
@@ -39,9 +39,93 @@ type CurvePoint struct {
Y float64 `json:"y"`
}
// ColorParams holds the parameters of the "Цвет" (color) tool: white balance,
// vibrance/saturation, the per-hue color mixer, color grading and a simple
// per-channel color correction. Percent values use -100..100, wheel hues use
// 0..360.
type ColorParams struct {
Temperature float64 `json:"temperature"`
Tint float64 `json:"tint"`
Vibrance float64 `json:"vibrance"`
Saturation float64 `json:"saturation"`
Mixer ColorMixer `json:"mixer"`
Grading ColorGrading `json:"grading"`
Correction ColorCorrection `json:"correction"`
}
// ColorMixer holds one HSL band per hue, mirroring the Lightroom color mixer.
type ColorMixer struct {
Red MixerBand `json:"red"`
Orange MixerBand `json:"orange"`
Yellow MixerBand `json:"yellow"`
Green MixerBand `json:"green"`
Aqua MixerBand `json:"aqua"`
Blue MixerBand `json:"blue"`
Purple MixerBand `json:"purple"`
Magenta MixerBand `json:"magenta"`
}
// MixerBand is a hue/saturation/luminance adjustment of one color band.
type MixerBand struct {
Hue float64 `json:"hue"`
Saturation float64 `json:"saturation"`
Luminance float64 `json:"luminance"`
}
// ColorGrading holds the three color-grading wheels and their blending.
type ColorGrading struct {
Shadows ColorWheel `json:"shadows"`
Midtones ColorWheel `json:"midtones"`
Highlights ColorWheel `json:"highlights"`
Blending float64 `json:"blending"`
Balance float64 `json:"balance"`
}
// ColorWheel is a hue, strength and luminance used by color grading.
type ColorWheel struct {
Hue float64 `json:"hue"`
Saturation float64 `json:"saturation"`
Luminance float64 `json:"luminance"`
}
// ColorCorrection is a simple per-channel gain, in percent.
type ColorCorrection struct {
Red float64 `json:"red"`
Green float64 `json:"green"`
Blue float64 `json:"blue"`
}
// mixerBand is a color band with its hue center in degrees.
type mixerBand struct {
center float64
hue float64
saturation float64
luminance float64
}
// bands returns the mixer bands in a stable order.
func (m ColorMixer) bands() [8]mixerBand {
return [8]mixerBand{
{0, m.Red.Hue, m.Red.Saturation, m.Red.Luminance},
{30, m.Orange.Hue, m.Orange.Saturation, m.Orange.Luminance},
{60, m.Yellow.Hue, m.Yellow.Saturation, m.Yellow.Luminance},
{120, m.Green.Hue, m.Green.Saturation, m.Green.Luminance},
{180, m.Aqua.Hue, m.Aqua.Saturation, m.Aqua.Luminance},
{240, m.Blue.Hue, m.Blue.Saturation, m.Blue.Luminance},
{280, m.Purple.Hue, m.Purple.Saturation, m.Purple.Luminance},
{320, m.Magenta.Hue, m.Magenta.Saturation, m.Magenta.Luminance},
}
}
// wheels returns the grading wheels in a stable order.
func (g ColorGrading) wheels() [3]ColorWheel {
return [3]ColorWheel{g.Shadows, g.Midtones, g.Highlights}
}
// EditParams is the full set of non-destructive editing parameters of a photo.
type EditParams struct {
Light LightParams `json:"light"`
Color ColorParams `json:"color"`
}
// Limits of the editing parameters. They are enforced on write so a hand-made
@@ -82,9 +166,54 @@ func (p EditParams) Normalized() EditParams {
p.Light.Curve.Red = normalizeCurve(p.Light.Curve.Red)
p.Light.Curve.Green = normalizeCurve(p.Light.Curve.Green)
p.Light.Curve.Blue = normalizeCurve(p.Light.Curve.Blue)
p.Color = p.Color.Normalized()
return p
}
// Normalized returns a copy of the color parameters clamped to their ranges.
func (c ColorParams) Normalized() ColorParams {
c.Temperature = clampFloat(c.Temperature, -MaxPercent, MaxPercent)
c.Tint = clampFloat(c.Tint, -MaxPercent, MaxPercent)
c.Vibrance = clampFloat(c.Vibrance, -MaxPercent, MaxPercent)
c.Saturation = clampFloat(c.Saturation, -MaxPercent, MaxPercent)
bands := c.Mixer.bands()
for i := range bands {
bands[i].hue = clampFloat(bands[i].hue, -MaxPercent, MaxPercent)
bands[i].saturation = clampFloat(bands[i].saturation, -MaxPercent, MaxPercent)
bands[i].luminance = clampFloat(bands[i].luminance, -MaxPercent, MaxPercent)
}
c.Mixer = ColorMixer{
Red: MixerBand{bands[0].hue, bands[0].saturation, bands[0].luminance},
Orange: MixerBand{bands[1].hue, bands[1].saturation, bands[1].luminance},
Yellow: MixerBand{bands[2].hue, bands[2].saturation, bands[2].luminance},
Green: MixerBand{bands[3].hue, bands[3].saturation, bands[3].luminance},
Aqua: MixerBand{bands[4].hue, bands[4].saturation, bands[4].luminance},
Blue: MixerBand{bands[5].hue, bands[5].saturation, bands[5].luminance},
Purple: MixerBand{bands[6].hue, bands[6].saturation, bands[6].luminance},
Magenta: MixerBand{bands[7].hue, bands[7].saturation, bands[7].luminance},
}
wheels := c.Grading.wheels()
for i := range wheels {
wheels[i].Hue = clampFloat(wheels[i].Hue, 0, 360)
wheels[i].Saturation = clampFloat(wheels[i].Saturation, -MaxPercent, MaxPercent)
wheels[i].Luminance = clampFloat(wheels[i].Luminance, -MaxPercent, MaxPercent)
}
c.Grading = ColorGrading{
Shadows: wheels[0],
Midtones: wheels[1],
Highlights: wheels[2],
Blending: clampFloat(c.Grading.Blending, 0, MaxPercent),
Balance: clampFloat(c.Grading.Balance, -MaxPercent, MaxPercent),
}
c.Correction.Red = clampFloat(c.Correction.Red, -MaxPercent, MaxPercent)
c.Correction.Green = clampFloat(c.Correction.Green, -MaxPercent, MaxPercent)
c.Correction.Blue = clampFloat(c.Correction.Blue, -MaxPercent, MaxPercent)
return c
}
// IsEmpty reports whether the parameters describe an untouched photo.
func (p EditParams) IsEmpty() bool {
l := p.Light
@@ -99,7 +228,26 @@ func (p EditParams) IsEmpty() bool {
}
}
}
return true
return p.Color.IsEmpty()
}
// IsEmpty reports whether the color parameters leave the image untouched.
func (c ColorParams) IsEmpty() bool {
if c.Temperature != 0 || c.Tint != 0 || c.Vibrance != 0 || c.Saturation != 0 {
return false
}
for _, b := range c.Mixer.bands() {
if b.hue != 0 || b.saturation != 0 || b.luminance != 0 {
return false
}
}
for _, w := range c.Grading.wheels() {
// A hue without strength or luminance has no effect.
if w.Saturation != 0 || w.Luminance != 0 {
return false
}
}
return c.Correction.Red == 0 && c.Correction.Green == 0 && c.Correction.Blue == 0
}
// JSON encodes the parameters for storage in the photo metadata.
@@ -267,3 +415,229 @@ func (a lightAdjuster) apply(v float64, master, channel *[256]uint8) float64 {
func adjustChannel(v float64, p LightParams, master, channel *[256]uint8) float64 {
return newLightAdjuster(p).apply(v, master, channel)
}
// rgbToHSL converts RGB (each 0..255) to HSL: hue in degrees, saturation and
// lightness in 0..1. It uses only arithmetic, so it is easy to mirror in the
// client and in the WASM module.
func rgbToHSL(r, g, b float64) (h, s, l float64) {
r /= 255
g /= 255
b /= 255
max := math.Max(r, math.Max(g, b))
min := math.Min(r, math.Min(g, b))
l = (max + min) / 2
if max == min {
return 0, 0, l
}
d := max - min
if l > 0.5 {
s = d / (2 - max - min)
} else {
s = d / (max + min)
}
switch max {
case r:
h = (g - b) / d
if g < b {
h += 6
}
case g:
h = (b-r)/d + 2
case b:
h = (r-g)/d + 4
}
h *= 60
return h, s, l
}
// hslToRGB converts HSL (hue in degrees, saturation and lightness in 0..1)
// back to RGB in 0..255.
func hslToRGB(h, s, l float64) (r, g, b float64) {
if s == 0 {
v := l * 255
return v, v, v
}
var q float64
if l < 0.5 {
q = l * (1 + s)
} else {
q = l + s - l*s
}
p := 2*l - q
hk := h / 360
r = hueToRGB(p, q, hk+1.0/3)
g = hueToRGB(p, q, hk)
b = hueToRGB(p, q, hk-1.0/3)
return r * 255, g * 255, b * 255
}
// hueToRGB is the per-channel helper of hslToRGB.
func hueToRGB(p, q, t float64) float64 {
if t < 0 {
t++
}
if t > 1 {
t--
}
if t < 1.0/6 {
return p + (q-p)*6*t
}
if t < 1.0/2 {
return q
}
if t < 2.0/3 {
return p + (q-p)*(2.0/3-t)*6
}
return p
}
// bandWeight returns a triangular weight of a hue around a band center.
func bandWeight(h, center float64) float64 {
d := math.Abs(h - center)
if d > 180 {
d = 360 - d
}
return clamp01(1 - d/60)
}
// colorAdjuster holds the precomputed factors of the color adjustments. The
// order of the operations is fixed and mirrored by the client-side preview and
// the WASM module, so the live preview and the rendered result match.
type colorAdjuster struct {
temperature float64
tint float64
vibrance float64
saturation float64
mixer [8]mixerBand
hasMixer bool
wheels [3]ColorWheel
luminances [3]float64
blending float64
balance float64
correction [3]float64
tints [3][3]float64
}
func newColorAdjuster(p ColorParams) colorAdjuster {
a := colorAdjuster{
temperature: p.Temperature / 100,
tint: p.Tint / 100,
vibrance: p.Vibrance / 100,
saturation: p.Saturation / 100,
mixer: p.Mixer.bands(),
wheels: p.Grading.wheels(),
blending: p.Grading.Blending / 100,
balance: p.Grading.Balance / 100,
correction: [3]float64{p.Correction.Red / 100, p.Correction.Green / 100, p.Correction.Blue / 100},
}
for _, band := range a.mixer {
if band.hue != 0 || band.saturation != 0 || band.luminance != 0 {
a.hasMixer = true
break
}
}
for i, wheel := range a.wheels {
a.luminances[i] = wheel.Luminance / 100
r, g, b := hslToRGB(wheel.Hue, 1, 0.5)
a.tints[i] = [3]float64{r, g, b}
}
return a
}
// apply color-corrects one RGB pixel (each 0..255) and returns the result.
func (a colorAdjuster) apply(r, g, b float64) (float64, float64, float64) {
// Баланс белого: тёплый/холодный и зелёный/пурпурный сдвиг.
r *= 1 + 0.20*a.temperature + 0.10*a.tint
g *= 1 - 0.20*a.tint
b *= 1 - 0.20*a.temperature + 0.10*a.tint
r = clampFloat(r, 0, 255)
g = clampFloat(g, 0, 255)
b = clampFloat(b, 0, 255)
// Быстрый путь: только колёса/яркость, без микшера и насыщенности —
// HSL-переход не нужен, lightness считается напрямую.
fast := !a.hasMixer && a.saturation == 0 && a.vibrance == 0
var weights [3]float64
var rr, gg, bb float64
if fast {
max := math.Max(r, math.Max(g, b))
min := math.Min(r, math.Min(g, b))
l := (max + min) / 510
lb := clamp01(l + a.balance*0.5)
shadowW := (1 - lb) * (1 - lb)
shadowW *= shadowW
highlightW := lb * lb
highlightW *= highlightW
weights = [3]float64{shadowW, 1 - shadowW - highlightW, highlightW}
targetL := l
for i := range a.wheels {
targetL += a.luminances[i] * 0.2 * weights[i]
}
targetL = clamp01(targetL)
delta := (targetL - l) * 255
rr = r + delta
gg = g + delta
bb = b + delta
} else {
// RGB -> HSL.
h, s, l := rgbToHSL(r, g, b)
// Насыщенность и вибрация: вибрация сильнее влияет на приглушённые цвета.
if a.saturation != 0 || a.vibrance != 0 {
s = clamp01(s * (1 + a.saturation + a.vibrance*(1-s)))
}
// Цветовой микшер: сдвиг оттенка, насыщенности и яркости по зонам.
if a.hasMixer {
for _, band := range a.mixer {
w := bandWeight(h, band.center)
if w == 0 {
continue
}
h += (band.hue / 100) * 30 * w
s = clamp01(s * (1 + (band.saturation/100)*w))
l = clamp01(l + (band.luminance/100)*0.2*w)
}
}
h = math.Mod(h, 360)
if h < 0 {
h += 360
}
// Зоны яркости: веса в сумме дают 1, баланс сдвигает границы.
lb := clamp01(l + a.balance*0.5)
// Более узкие зоны: колесо теней почти не задевает средние тона.
shadowW := (1 - lb) * (1 - lb)
shadowW *= shadowW
highlightW := lb * lb
highlightW *= highlightW
weights = [3]float64{shadowW, 1 - shadowW - highlightW, highlightW}
for i := range a.wheels {
l = clamp01(l + a.luminances[i]*0.2*weights[i])
}
// HSL -> RGB.
rr, gg, bb = hslToRGB(h, s, l)
}
// Точечный цвет: три колеса по зонам яркости.
blend := a.blending
for i, wheel := range a.wheels {
strength := (wheel.Saturation / 100) * 0.5 * blend * weights[i]
if strength == 0 {
continue
}
tint := a.tints[i]
rr = rr*(1-strength) + tint[0]*strength
gg = gg*(1-strength) + tint[1]*strength
bb = bb*(1-strength) + tint[2]*strength
}
// Цветокоррекция: поканальный сдвиг.
rr *= 1 + a.correction[0]*0.5
gg *= 1 + a.correction[1]*0.5
bb *= 1 + a.correction[2]*0.5
return clampFloat(rr, 0, 255), clampFloat(gg, 0, 255), clampFloat(bb, 0, 255)
}