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"` } // EditParams is the full set of non-destructive editing parameters of a photo. type EditParams struct { Light LightParams `json:"light"` } // 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) return p } // 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 true } // 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) }