Files
ds-galo/backend/internal/photos/edit.go
T
iu c953008a86 feat(photos): добавить эффекты и настройки панели редактора
- текстура, чёткость, дымка, виньетка и зерно в неразрушающем редакторе
- живой WebGL2-предпросмотр эффектов и CPU-фолбэк
- секции редактора свёрнуты по умолчанию, состояние в localStorage
- вкладка «Редактирование» доступна только из просмотрщика
2026-10-24 22:57:30 +03:00

927 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package photos
import (
"encoding/json"
"errors"
"math"
"sort"
"strings"
)
// 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"`
}
// EffectsParams holds the parameters of the "Эффекты" (effects) tool: texture,
// clarity, dehaze, vignette and grain. It is applied after the light and color
// adjustments, because it works on the already corrected frame.
type EffectsParams struct {
Texture float64 `json:"texture"`
Clarity float64 `json:"clarity"`
Dehaze float64 `json:"dehaze"`
Vignette VignetteParams `json:"vignette"`
Grain GrainParams `json:"grain"`
}
// VignetteParams holds the parameters of the "Виньетка" tool. The mode picks
// the way the edges are treated ("light" darkens or brightens them, "color"
// tints them and "colorize" fills them with the chosen color), amount is the
// signed strength in percent, and the rest controls the geometry of the effect.
//
// midpointSet and featherSet record whether the client sent the field: an
// explicit zero is a valid geometry value and must not be replaced by the
// default, while an omitted field gets the default.
type VignetteParams struct {
Mode string `json:"mode"`
Amount float64 `json:"amount"`
Midpoint float64 `json:"midpoint"`
Feather float64 `json:"feather"`
Roundness float64 `json:"roundness"`
Highlights float64 `json:"highlights"`
Color string `json:"color"`
midpointSet bool
featherSet bool
}
// UnmarshalJSON remembers which defaultable fields the client actually sent.
func (v *VignetteParams) UnmarshalJSON(data []byte) error {
var raw struct {
Mode *string `json:"mode"`
Amount *float64 `json:"amount"`
Midpoint *float64 `json:"midpoint"`
Feather *float64 `json:"feather"`
Roundness *float64 `json:"roundness"`
Highlights *float64 `json:"highlights"`
Color *string `json:"color"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
*v = VignetteParams{}
if raw.Mode != nil {
v.Mode = *raw.Mode
}
if raw.Amount != nil {
v.Amount = *raw.Amount
}
if raw.Midpoint != nil {
v.Midpoint = *raw.Midpoint
v.midpointSet = true
}
if raw.Feather != nil {
v.Feather = *raw.Feather
v.featherSet = true
}
if raw.Roundness != nil {
v.Roundness = *raw.Roundness
}
if raw.Highlights != nil {
v.Highlights = *raw.Highlights
}
if raw.Color != nil {
v.Color = *raw.Color
}
return nil
}
// GrainParams holds the parameters of the "Зерно" tool. Amount is the strength,
// size the scale of the noise cells, roughness the mix of fine and smooth
// noise.
//
// sizeSet and roughnessSet record whether the client sent the field: an
// explicit zero is a valid value and must not be replaced by the default.
type GrainParams struct {
Amount float64 `json:"amount"`
Size float64 `json:"size"`
Roughness float64 `json:"roughness"`
sizeSet bool
roughnessSet bool
}
// UnmarshalJSON remembers which defaultable fields the client actually sent.
func (g *GrainParams) UnmarshalJSON(data []byte) error {
var raw struct {
Amount *float64 `json:"amount"`
Size *float64 `json:"size"`
Roughness *float64 `json:"roughness"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
*g = GrainParams{}
if raw.Amount != nil {
g.Amount = *raw.Amount
}
if raw.Size != nil {
g.Size = *raw.Size
g.sizeSet = true
}
if raw.Roughness != nil {
g.Roughness = *raw.Roughness
g.roughnessSet = true
}
return nil
}
// Modes of the "Виньетка" tool. Any other mode is normalized to VignetteLight.
const (
VignetteLight = "light"
VignetteColor = "color"
VignetteColorize = "colorize"
)
// Structural defaults of the effects. They are filled by Normalized, so the
// zero value and an explicit "defaults" request mean the same thing and a photo
// without the "effects" key reads as untouched.
const (
DefaultVignetteMidpoint = 50.0
DefaultVignetteFeather = 50.0
DefaultGrainSize = 25.0
DefaultGrainRoughness = 50.0
)
// DefaultVignetteColor is the color used when the request carries no usable
// hex color.
const DefaultVignetteColor = "#000000"
// 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"`
Effects EffectsParams `json:"effects"`
}
// 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()
p.Effects = p.Effects.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
}
// Normalized returns a copy of the effects clamped to their ranges, with the
// structural defaults filled in. Normalization is idempotent: applying it to an
// already normalized value returns it unchanged, which keeps repeated saves and
// reads stable.
func (e EffectsParams) Normalized() EffectsParams {
e.Texture = clampFloat(e.Texture, -MaxPercent, MaxPercent)
e.Clarity = clampFloat(e.Clarity, -MaxPercent, MaxPercent)
e.Dehaze = clampFloat(e.Dehaze, -MaxPercent, MaxPercent)
e.Vignette = e.Vignette.Normalized()
e.Grain = e.Grain.Normalized()
return e
}
// Normalized returns a copy of the vignette parameters clamped to their ranges.
// The midpoint and feather defaults are applied only when the client omitted the
// field; an explicit zero stays zero. Clamping happens before defaulting so the
// operation stays idempotent.
func (v VignetteParams) Normalized() VignetteParams {
v.Mode = normalizeVignetteMode(v.Mode)
v.Amount = clampFloat(v.Amount, -MaxPercent, MaxPercent)
v.Midpoint = clampFloat(v.Midpoint, 0, MaxPercent)
if v.Midpoint == 0 && !v.midpointSet {
v.Midpoint = DefaultVignetteMidpoint
}
v.Feather = clampFloat(v.Feather, 0, MaxPercent)
if v.Feather == 0 && !v.featherSet {
v.Feather = DefaultVignetteFeather
}
v.Roundness = clampFloat(v.Roundness, -MaxPercent, MaxPercent)
v.Highlights = clampFloat(v.Highlights, 0, MaxPercent)
v.Color = normalizeHexColor(v.Color)
return v
}
// Normalized returns a copy of the grain parameters clamped to their ranges.
// The size and roughness defaults are applied only when the client omitted the
// field; an explicit zero stays zero.
func (g GrainParams) Normalized() GrainParams {
g.Amount = clampFloat(g.Amount, 0, MaxPercent)
g.Size = clampFloat(g.Size, 0, MaxPercent)
if g.Size == 0 && !g.sizeSet {
g.Size = DefaultGrainSize
}
g.Roughness = clampFloat(g.Roughness, 0, MaxPercent)
if g.Roughness == 0 && !g.roughnessSet {
g.Roughness = DefaultGrainRoughness
}
return g
}
// IsEmpty reports whether the effects leave the image untouched. Only the
// strengths count: the structural defaults (mode, midpoint, feather, size,
// roughness, color) do not turn into an edit by themselves.
func (e EffectsParams) IsEmpty() bool {
return e.Texture == 0 && e.Clarity == 0 && e.Dehaze == 0 &&
e.Vignette.Amount == 0 && e.Grain.Amount == 0
}
// normalizeVignetteMode maps an unknown or empty mode to the light one.
func normalizeVignetteMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case VignetteColor:
return VignetteColor
case VignetteColorize:
return VignetteColorize
default:
return VignetteLight
}
}
// normalizeHexColor returns the lower-case "#rrggbb" form of a valid color and
// the default black for anything unusable.
func normalizeHexColor(color string) string {
color = strings.TrimSpace(color)
if len(color) != 7 || color[0] != '#' {
return DefaultVignetteColor
}
for i := 1; i < len(color); i++ {
c := color[i]
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
return DefaultVignetteColor
}
}
return strings.ToLower(color)
}
// 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() && p.Effects.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)
}