feat(web): вкладка «Редактирование» с инструментом «Свет»
В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»: ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая с выбором канала. Правки сохраняются автоматически, переключатель «Сброс» удаляет параметры правок и отредактированную версию фото. Редактирование неразрушающее: параметры хранятся в метаданных фото, а отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb, edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется, скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают отредактированную версию, когда она отрендерена.
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user