feat(photos): добавить эффекты и настройки панели редактора
- текстура, чёткость, дымка, виньетка и зерно в неразрушающем редакторе - живой WebGL2-предпросмотр эффектов и CPU-фолбэк - секции редактора свёрнуты по умолчанию, состояние в localStorage - вкладка «Редактирование» доступна только из просмотрщика
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Edit parameters are stored in the photo metadata and describe a
|
||||
@@ -15,12 +16,12 @@ import (
|
||||
// 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -95,6 +96,140 @@ type ColorCorrection struct {
|
||||
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
|
||||
@@ -124,8 +259,9 @@ func (g ColorGrading) wheels() [3]ColorWheel {
|
||||
|
||||
// EditParams is the full set of non-destructive editing parameters of a photo.
|
||||
type EditParams struct {
|
||||
Light LightParams `json:"light"`
|
||||
Color ColorParams `json:"color"`
|
||||
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
|
||||
@@ -167,6 +303,7 @@ func (p EditParams) Normalized() EditParams {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -214,6 +351,92 @@ func (c ColorParams) Normalized() ColorParams {
|
||||
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
|
||||
@@ -228,7 +451,7 @@ func (p EditParams) IsEmpty() bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
return p.Color.IsEmpty()
|
||||
return p.Color.IsEmpty() && p.Effects.IsEmpty()
|
||||
}
|
||||
|
||||
// IsEmpty reports whether the color parameters leave the image untouched.
|
||||
|
||||
Reference in New Issue
Block a user