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, and the // values in between are interpolated linearly. 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 } for i := 0; i < 256; i++ { x := float64(i) / 255 lut[i] = uint8(math.Round(clamp01(curveEval(pts, x)) * 255)) } return lut } // curveEval evaluates the curve at x using linear interpolation between the // surrounding control points. func curveEval(points []CurvePoint, x float64) float64 { if x <= points[0].X { return points[0].Y } last := points[len(points)-1] if x >= last.X { return last.Y } for i := 1; i < len(points); i++ { if x > points[i].X { continue } x0, y0 := points[i-1].X, points[i-1].Y x1, y1 := points[i].X, points[i].Y if x1-x0 < 1e-9 { return y1 } t := (x - x0) / (x1 - x0) return y0 + t*(y1-y0) } return last.Y } // 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) }