- PCHIP для предпросмотра и серверного рендера - пресеты всех каналов в профиле, импорт и экспорт - мерж настроек auth без потери разделов - сохранение вкладки и правок текущего фото при переключении
704 lines
20 KiB
Go
704 lines
20 KiB
Go
package photos
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"math"
|
||
"sort"
|
||
)
|
||
|
||
// Edit parameters are stored in the photo metadata and describe a
|
||
// non-destructive render: the original object is never touched, the edited
|
||
// image is produced as a separate object.
|
||
|
||
// LightParams holds the parameters of the "Свет" (light) tool. The slider
|
||
// ranges mirror the ones Lightroom exposes: exposure in stops, everything else
|
||
// in percent.
|
||
type LightParams struct {
|
||
Exposure float64 `json:"exposure"`
|
||
Contrast float64 `json:"contrast"`
|
||
Highlights float64 `json:"highlights"`
|
||
Shadows float64 `json:"shadows"`
|
||
Whites float64 `json:"whites"`
|
||
Blacks float64 `json:"blacks"`
|
||
Curve CurveParams `json:"curve"`
|
||
}
|
||
|
||
// CurveParams holds one tone curve per channel. An empty channel means the
|
||
// identity (linear) curve.
|
||
type CurveParams struct {
|
||
RGB []CurvePoint `json:"rgb,omitempty"`
|
||
Red []CurvePoint `json:"red,omitempty"`
|
||
Green []CurvePoint `json:"green,omitempty"`
|
||
Blue []CurvePoint `json:"blue,omitempty"`
|
||
}
|
||
|
||
// CurvePoint is a control point of a tone curve, in the 0..1 range.
|
||
type CurvePoint struct {
|
||
X float64 `json:"x"`
|
||
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
|
||
// request can never push the renderer into an extreme state.
|
||
const (
|
||
MaxExposureStops = 5.0
|
||
MaxPercent = 100.0
|
||
MaxCurvePoints = 16
|
||
)
|
||
|
||
// ErrInvalidEditParams is returned when the client sends unusable parameters.
|
||
var ErrInvalidEditParams = errors.New("invalid edit params")
|
||
|
||
// ParseEditParams decodes and normalizes the editing parameters sent by a
|
||
// client. Values outside the allowed range are clamped, and the curve points
|
||
// are sorted and de-duplicated.
|
||
func ParseEditParams(raw []byte) (EditParams, error) {
|
||
var p EditParams
|
||
if len(raw) == 0 {
|
||
return p, nil
|
||
}
|
||
if err := json.Unmarshal(raw, &p); err != nil {
|
||
return EditParams{}, ErrInvalidEditParams
|
||
}
|
||
p = p.Normalized()
|
||
return p, nil
|
||
}
|
||
|
||
// Normalized returns a copy with every value clamped to the allowed range.
|
||
func (p EditParams) Normalized() EditParams {
|
||
p.Light.Exposure = clampFloat(p.Light.Exposure, -MaxExposureStops, MaxExposureStops)
|
||
p.Light.Contrast = clampFloat(p.Light.Contrast, -MaxPercent, MaxPercent)
|
||
p.Light.Highlights = clampFloat(p.Light.Highlights, -MaxPercent, MaxPercent)
|
||
p.Light.Shadows = clampFloat(p.Light.Shadows, -MaxPercent, MaxPercent)
|
||
p.Light.Whites = clampFloat(p.Light.Whites, -MaxPercent, MaxPercent)
|
||
p.Light.Blacks = clampFloat(p.Light.Blacks, -MaxPercent, MaxPercent)
|
||
p.Light.Curve.RGB = normalizeCurve(p.Light.Curve.RGB)
|
||
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
|
||
if l.Exposure != 0 || l.Contrast != 0 || l.Highlights != 0 || l.Shadows != 0 ||
|
||
l.Whites != 0 || l.Blacks != 0 {
|
||
return false
|
||
}
|
||
for _, pts := range [][]CurvePoint{l.Curve.RGB, l.Curve.Red, l.Curve.Green, l.Curve.Blue} {
|
||
for _, pt := range pts {
|
||
if math.Abs(pt.X-pt.Y) > 1e-6 {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
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.
|
||
func (p EditParams) JSON() []byte {
|
||
if p.IsEmpty() {
|
||
return []byte("{}")
|
||
}
|
||
data, err := json.Marshal(p)
|
||
if err != nil {
|
||
return []byte("{}")
|
||
}
|
||
return data
|
||
}
|
||
|
||
// normalizeCurve clamps the points to the unit square, keeps them sorted by X
|
||
// and drops duplicates.
|
||
func normalizeCurve(points []CurvePoint) []CurvePoint {
|
||
if len(points) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]CurvePoint, 0, len(points))
|
||
for _, pt := range points {
|
||
out = append(out, CurvePoint{
|
||
X: clampFloat(pt.X, 0, 1),
|
||
Y: clampFloat(pt.Y, 0, 1),
|
||
})
|
||
}
|
||
sort.SliceStable(out, func(i, j int) bool { return out[i].X < out[j].X })
|
||
deduped := out[:0]
|
||
for i, pt := range out {
|
||
if i > 0 && math.Abs(pt.X-deduped[len(deduped)-1].X) < 1e-6 {
|
||
continue
|
||
}
|
||
deduped = append(deduped, pt)
|
||
}
|
||
if len(deduped) > MaxCurvePoints {
|
||
deduped = deduped[:MaxCurvePoints]
|
||
}
|
||
return deduped
|
||
}
|
||
|
||
func clampFloat(v, min, max float64) float64 {
|
||
if math.IsNaN(v) {
|
||
return 0
|
||
}
|
||
if v < min {
|
||
return min
|
||
}
|
||
if v > max {
|
||
return max
|
||
}
|
||
return v
|
||
}
|
||
|
||
func clamp01(v float64) float64 {
|
||
return clampFloat(v, 0, 1)
|
||
}
|
||
|
||
func clampInt(v, min, max int) int {
|
||
if v < min {
|
||
return min
|
||
}
|
||
if v > max {
|
||
return max
|
||
}
|
||
return v
|
||
}
|
||
|
||
// curveLUT builds a 256-entry lookup table from the control points of a
|
||
// curve. The first and last points extend to the edges of the range as flat
|
||
// shelves, and the values in between are interpolated with a monotone cubic
|
||
// Hermite spline (PCHIP, Fritsch–Carlson).
|
||
func curveLUT(points []CurvePoint) [256]uint8 {
|
||
var lut [256]uint8
|
||
pts := normalizeCurve(points)
|
||
if len(pts) < 2 {
|
||
for i := range lut {
|
||
lut[i] = uint8(i)
|
||
}
|
||
return lut
|
||
}
|
||
tangents := curveTangents(pts)
|
||
for i := 0; i < 256; i++ {
|
||
x := float64(i) / 255
|
||
lut[i] = uint8(math.Round(clamp01(curveEvalWithTangents(pts, tangents, x)) * 255))
|
||
}
|
||
return lut
|
||
}
|
||
|
||
// curveTangents returns the PCHIP slopes at the control points. Interior
|
||
// tangents are weighted harmonic means of the neighbouring slopes; endpoint
|
||
// tangents use the one-sided three-point formula with the Fritsch–Carlson
|
||
// shape-preserving clamp.
|
||
func curveTangents(points []CurvePoint) []float64 {
|
||
n := len(points)
|
||
if n < 2 {
|
||
return nil
|
||
}
|
||
h := make([]float64, n-1)
|
||
delta := make([]float64, n-1)
|
||
for i := 0; i < n-1; i++ {
|
||
h[i] = points[i+1].X - points[i].X
|
||
delta[i] = (points[i+1].Y - points[i].Y) / h[i]
|
||
}
|
||
m := make([]float64, n)
|
||
if n == 2 {
|
||
m[0] = delta[0]
|
||
m[1] = delta[0]
|
||
return m
|
||
}
|
||
m[0] = endpointTangent(h[0], h[1], delta[0], delta[1])
|
||
m[n-1] = endpointTangent(h[n-2], h[n-3], delta[n-2], delta[n-3])
|
||
for i := 1; i < n-1; i++ {
|
||
if delta[i-1]*delta[i] <= 0 {
|
||
m[i] = 0
|
||
continue
|
||
}
|
||
w1 := 2*h[i] + h[i-1]
|
||
w2 := h[i] + 2*h[i-1]
|
||
m[i] = (w1 + w2) / (w1/delta[i-1] + w2/delta[i])
|
||
}
|
||
return m
|
||
}
|
||
|
||
// endpointTangent is the one-sided endpoint slope of the PCHIP spline.
|
||
func endpointTangent(h0, h1, d0, d1 float64) float64 {
|
||
m := ((2*h0+h1)*d0 - h0*d1) / (h0 + h1)
|
||
if m*d0 <= 0 {
|
||
return 0
|
||
}
|
||
if d0*d1 < 0 && math.Abs(m) > math.Abs(3*d0) {
|
||
return 3 * d0
|
||
}
|
||
return m
|
||
}
|
||
|
||
// curveEval evaluates the curve at x with a monotone cubic Hermite spline.
|
||
// Outside the first and last points the value is constant.
|
||
func curveEval(points []CurvePoint, x float64) float64 {
|
||
if len(points) < 2 {
|
||
return x
|
||
}
|
||
return curveEvalWithTangents(points, curveTangents(points), x)
|
||
}
|
||
|
||
func curveEvalWithTangents(points []CurvePoint, tangents []float64, x float64) float64 {
|
||
if x <= points[0].X {
|
||
return points[0].Y
|
||
}
|
||
last := points[len(points)-1]
|
||
if x >= last.X {
|
||
return last.Y
|
||
}
|
||
i := 1
|
||
for i < len(points)-1 && x > points[i].X {
|
||
i++
|
||
}
|
||
p0, p1 := points[i-1], points[i]
|
||
h := p1.X - p0.X
|
||
if h < 1e-9 {
|
||
return p1.Y
|
||
}
|
||
t := (x - p0.X) / h
|
||
t2 := t * t
|
||
t3 := t2 * t
|
||
h00 := 2*t3 - 3*t2 + 1
|
||
h10 := t3 - 2*t2 + t
|
||
h01 := -2*t3 + 3*t2
|
||
h11 := t3 - t2
|
||
return h00*p0.Y + h10*h*tangents[i-1] + h01*p1.Y + h11*h*tangents[i]
|
||
}
|
||
|
||
// lightAdjuster holds the precomputed factors of the light adjustments. The
|
||
// factors are calculated once per render, so the per-pixel loop stays cheap.
|
||
type lightAdjuster struct {
|
||
exposure float64
|
||
contrast float64
|
||
highlights float64
|
||
shadows float64
|
||
whites float64
|
||
blacks float64
|
||
}
|
||
|
||
func newLightAdjuster(p LightParams) lightAdjuster {
|
||
return lightAdjuster{
|
||
exposure: math.Pow(2, p.Exposure),
|
||
contrast: 1 + p.Contrast/100,
|
||
highlights: p.Highlights / 100 * 80,
|
||
shadows: p.Shadows / 100 * 80,
|
||
whites: p.Whites / 100 * 80,
|
||
blacks: p.Blacks / 100 * 80,
|
||
}
|
||
}
|
||
|
||
// apply maps a single channel value through the adjustments and the tone
|
||
// curves: the master curve first, the channel curve second. The order of the
|
||
// operations is fixed and mirrored by the client-side preview, so the live
|
||
// preview and the rendered result match.
|
||
func (a lightAdjuster) apply(v float64, master, channel *[256]uint8) float64 {
|
||
// Экспозиция (стопы).
|
||
v *= a.exposure
|
||
// Контраст вокруг средней точки.
|
||
v = 128 + (v-128)*a.contrast
|
||
// Света: верхняя часть диапазона.
|
||
w := clamp01((v - 128) / 127)
|
||
v += a.highlights * w * w
|
||
// Тени: нижняя часть диапазона.
|
||
w = clamp01((128 - v) / 128)
|
||
v += a.shadows * w * w
|
||
// Белые: самый верх диапазона.
|
||
w = clamp01((v - 192) / 63)
|
||
v += a.whites * w
|
||
// Чёрные: самый низ диапазона.
|
||
w = clamp01((64 - v) / 64)
|
||
v += a.blacks * w
|
||
// Тоновая кривая: общая, затем канальная.
|
||
idx := clampInt(int(v+0.5), 0, 255)
|
||
v = float64(master[idx])
|
||
idx = clampInt(int(v+0.5), 0, 255)
|
||
return float64(channel[idx])
|
||
}
|
||
|
||
// adjustChannel applies the light adjustments to a single channel value. It
|
||
// is the per-pixel helper of the renderer.
|
||
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)
|
||
}
|