Files
ds-galo/backend/internal/photos/edit.go
T
iu 0548b1ab5e feat(web): вкладка «Редактирование» с инструментом «Свет»
В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»:
ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая
с выбором канала. Правки сохраняются автоматически, переключатель «Сброс»
удаляет параметры правок и отредактированную версию фото.

Редактирование неразрушающее: параметры хранятся в метаданных фото, а
отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb,
edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется,
скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают
отредактированную версию, когда она отрендерена.
2026-10-20 23:06:24 +03:00

243 lines
6.8 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"`
}
// 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
}
// adjustChannel applies the light adjustments to a single channel value and
// then maps it through 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 adjustChannel(v float64, p LightParams, master, channel *[256]uint8) float64 {
// Экспозиция (стопы).
v *= math.Pow(2, p.Exposure)
// Контраст вокруг средней точки.
k := 1 + p.Contrast/100
v = 128 + (v-128)*k
// Света: верхняя часть диапазона.
w := math.Pow(clamp01((v-128)/127), 2)
v += p.Highlights/100*80*w
// Тени: нижняя часть диапазона.
w = math.Pow(clamp01((128-v)/128), 2)
v += p.Shadows/100*80*w
// Белые: самый верх диапазона.
w = clamp01((v - 192) / 63)
v += p.Whites/100*80*w
// Чёрные: самый низ диапазона.
w = clamp01((64 - v) / 64)
v += p.Blacks/100*80*w
// Тоновая кривая: общая, затем канальная.
idx := clampInt(int(math.Round(v)), 0, 255)
v = float64(master[idx])
idx = clampInt(int(math.Round(v)), 0, 255)
return clampFloat(float64(channel[idx]), 0, 255)
}