feat(photos): инструмент «Цвет» с WebGL-превью и пипеткой
- неразрушающие правки цвета: баланс белого, вибрация/насыщенность, цветовой микшер, колористика и поканальная коррекция - серверный рендер цвета в Go и паритетная реализация в WASM - живое превью на WebGL2 (кадр считает GPU), CPU/WASM-фолбэк - пипетка баланса белого по нейтральной точке - новые контролы: слайдеры с градиентами, цветовые колёса, двойной клик по контролу сбрасывает значение - тесты: параметры, рендер, паритет JS/WASM, e2e сохранения и сброса
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galo/internal/albums"
|
||||
"galo/internal/photos"
|
||||
)
|
||||
|
||||
// A color edit must survive the round trip: the parameters are stored in the
|
||||
// photo metadata, the edited version is rendered, and a reset returns the
|
||||
// photo to its original look.
|
||||
func TestColorEditRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
|
||||
defer closeDB()
|
||||
|
||||
user, _, err := st.auth.Register(ctx, "color@test.local", "", "password123", "C")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Color"})
|
||||
if err != nil {
|
||||
t.Fatalf("create album: %v", err)
|
||||
}
|
||||
p, err := st.photos.Upload(ctx, photos.UploadInput{
|
||||
AlbumID: album.ID, UserID: user.ID, Filename: "c.png", Data: makePNG(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
|
||||
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 2)
|
||||
w.ProcessOnce(ctx)
|
||||
|
||||
color := photos.ColorParams{Temperature: 25, Vibrance: 20}
|
||||
color.Mixer.Red.Saturation = 30
|
||||
color.Grading.Shadows = photos.ColorWheel{Hue: 220, Saturation: 40}
|
||||
edited, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{
|
||||
Light: photos.LightParams{Exposure: 0.3},
|
||||
Color: color,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("edit: %v", err)
|
||||
}
|
||||
if edited.EditParams == nil {
|
||||
t.Fatal("edit returned no parameters")
|
||||
}
|
||||
|
||||
w.ProcessOnce(ctx)
|
||||
got, err := st.photos.Get(ctx, p.ID, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.EditPreviewURL == "" {
|
||||
t.Fatalf("no edited preview url: %+v", got)
|
||||
}
|
||||
if _, ok := got.EditParams["color"]; !ok {
|
||||
t.Fatalf("color params not stored: %+v", got.EditParams)
|
||||
}
|
||||
|
||||
reset, err := st.photos.ResetEdit(ctx, p.ID, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
if len(reset.EditParams) != 0 {
|
||||
t.Fatalf("reset kept params: %+v", reset.EditParams)
|
||||
}
|
||||
if reset.EditURL != "" || reset.EditPreviewURL != "" {
|
||||
t.Fatalf("reset kept the edited version: %+v", reset)
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,93 @@ type CurvePoint struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// Limits of the editing parameters. They are enforced on write so a hand-made
|
||||
@@ -82,9 +166,54 @@ func (p EditParams) Normalized() EditParams {
|
||||
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()
|
||||
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
|
||||
}
|
||||
|
||||
// IsEmpty reports whether the parameters describe an untouched photo.
|
||||
func (p EditParams) IsEmpty() bool {
|
||||
l := p.Light
|
||||
@@ -99,7 +228,26 @@ func (p EditParams) IsEmpty() bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
return p.Color.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.
|
||||
@@ -267,3 +415,229 @@ func (a lightAdjuster) apply(v float64, master, channel *[256]uint8) float64 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -90,14 +90,68 @@ func applyLightRows(dst *image.NRGBA, from, to int, adj lightAdjuster, master, r
|
||||
}
|
||||
}
|
||||
|
||||
// decodeAndAdjust decodes the original and applies the light adjustments to
|
||||
// it, so the variants and the full-size version share one decode.
|
||||
// decodeAndAdjust decodes the original and applies the light and color
|
||||
// adjustments to it, so the variants and the full-size version share one
|
||||
// decode. Color is applied after light.
|
||||
func decodeAndAdjust(data []byte, p EditParams) (*image.NRGBA, error) {
|
||||
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return applyLight(img, p.Light), nil
|
||||
edited := applyLight(img, p.Light)
|
||||
if p.Color.IsEmpty() {
|
||||
return edited, nil
|
||||
}
|
||||
return applyColor(edited, p.Color), nil
|
||||
}
|
||||
|
||||
// applyColor applies the color adjustments in place to an image that already
|
||||
// has the light adjustments applied. The image is owned by the caller.
|
||||
func applyColor(dst *image.NRGBA, p ColorParams) *image.NRGBA {
|
||||
adj := newColorAdjuster(p)
|
||||
|
||||
rows := dst.Rect.Dy()
|
||||
workers := runtime.GOMAXPROCS(0)
|
||||
if workers > rows {
|
||||
workers = rows
|
||||
}
|
||||
if workers <= 1 {
|
||||
applyColorRows(dst, 0, rows, adj)
|
||||
return dst
|
||||
}
|
||||
// Рендер больших фото идёт по строкам параллельно.
|
||||
chunk := (rows + workers - 1) / workers
|
||||
var wg sync.WaitGroup
|
||||
for from := 0; from < rows; from += chunk {
|
||||
to := from + chunk
|
||||
if to > rows {
|
||||
to = rows
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(from, to int) {
|
||||
defer wg.Done()
|
||||
applyColorRows(dst, from, to, adj)
|
||||
}(from, to)
|
||||
}
|
||||
wg.Wait()
|
||||
return dst
|
||||
}
|
||||
|
||||
// applyColorRows applies the color adjustments to the given range of rows.
|
||||
func applyColorRows(dst *image.NRGBA, from, to int, adj colorAdjuster) {
|
||||
width := dst.Rect.Dx()
|
||||
for y := from; y < to; y++ {
|
||||
row := dst.Pix[y*dst.Stride : y*dst.Stride+width*4]
|
||||
for i := 0; i+3 < len(row); i += 4 {
|
||||
if row[i+3] == 0 {
|
||||
continue
|
||||
}
|
||||
r, g, b := adj.apply(float64(row[i]), float64(row[i+1]), float64(row[i+2]))
|
||||
row[i] = uint8(clampFloat(r+0.5, 0, 255))
|
||||
row[i+1] = uint8(clampFloat(g+0.5, 0, 255))
|
||||
row[i+2] = uint8(clampFloat(b+0.5, 0, 255))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// encodeEditedVariants renders the grid variants of an edited photo. They
|
||||
|
||||
@@ -182,3 +182,195 @@ func mustEncodePNG(t *testing.T, img image.Image) []byte {
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestParseEditParamsClampsColor(t *testing.T) {
|
||||
raw := []byte(`{"color":{"temperature":200,"tint":-300,"vibrance":50,"saturation":-500,"mixer":{"red":{"hue":150,"saturation":-200,"luminance":10}},"grading":{"shadows":{"hue":400,"saturation":150},"balance":-200},"correction":{"red":120}}}`)
|
||||
params, err := ParseEditParams(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
c := params.Color
|
||||
if c.Temperature != MaxPercent || c.Tint != -MaxPercent {
|
||||
t.Fatalf("wb = %v/%v, want clamped", c.Temperature, c.Tint)
|
||||
}
|
||||
if c.Saturation != -MaxPercent {
|
||||
t.Fatalf("saturation = %v, want %v", c.Saturation, -MaxPercent)
|
||||
}
|
||||
if c.Mixer.Red.Hue != MaxPercent || c.Mixer.Red.Saturation != -MaxPercent {
|
||||
t.Fatalf("mixer red = %+v, want clamped", c.Mixer.Red)
|
||||
}
|
||||
if c.Grading.Shadows.Hue != 360 || c.Grading.Shadows.Saturation != MaxPercent {
|
||||
t.Fatalf("grading shadows = %+v, want clamped", c.Grading.Shadows)
|
||||
}
|
||||
if c.Grading.Balance != -MaxPercent || c.Correction.Red != MaxPercent {
|
||||
t.Fatalf("grading/correction not clamped: %+v %+v", c.Grading, c.Correction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorParamsIsEmpty(t *testing.T) {
|
||||
var zero ColorParams
|
||||
if !zero.IsEmpty() {
|
||||
t.Fatal("zero color params must be empty")
|
||||
}
|
||||
vibrance := ColorParams{Vibrance: 1}
|
||||
if vibrance.IsEmpty() {
|
||||
t.Fatal("vibrance must not be empty")
|
||||
}
|
||||
mixer := ColorParams{}
|
||||
mixer.Mixer.Blue.Luminance = -10
|
||||
if mixer.IsEmpty() {
|
||||
t.Fatal("mixer band must not be empty")
|
||||
}
|
||||
// Оттенок колеса без силы не влияет на картинку.
|
||||
hueOnly := ColorParams{}
|
||||
hueOnly.Grading.Shadows.Hue = 120
|
||||
if !hueOnly.IsEmpty() {
|
||||
t.Fatal("grading hue without strength must count as empty")
|
||||
}
|
||||
grade := ColorParams{}
|
||||
grade.Grading.Highlights.Saturation = 10
|
||||
if grade.IsEmpty() {
|
||||
t.Fatal("grading strength must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorWhiteBalance(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 100, 100, 100, 255
|
||||
out := applyColor(src, ColorParams{Temperature: 100})
|
||||
if !(out.Pix[0] > out.Pix[1] && out.Pix[1] > out.Pix[2]) {
|
||||
t.Fatalf("warm white balance = %d/%d/%d, want r > g > b", out.Pix[0], out.Pix[1], out.Pix[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorSaturationMinus100IsGray(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 200, 50, 50, 255
|
||||
out := applyColor(src, ColorParams{Saturation: -100})
|
||||
if out.Pix[0] != out.Pix[1] || out.Pix[1] != out.Pix[2] {
|
||||
t.Fatalf("desaturated pixel = %d/%d/%d, want gray", out.Pix[0], out.Pix[1], out.Pix[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorSaturationPlus100(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 150, 120, 120, 255
|
||||
before := src.Pix[0] - src.Pix[1]
|
||||
out := applyColor(src, ColorParams{Saturation: 100})
|
||||
if out.Pix[0]-out.Pix[1] <= before {
|
||||
t.Fatalf("saturation did not spread channels: %d/%d/%d", out.Pix[0], out.Pix[1], out.Pix[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorGradingTintsShadows(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 40, 40, 40, 255
|
||||
p := ColorParams{}
|
||||
p.Grading.Blending = 50
|
||||
p.Grading.Shadows = ColorWheel{Hue: 240, Saturation: 100}
|
||||
out := applyColor(src, p)
|
||||
if out.Pix[2] <= out.Pix[0] {
|
||||
t.Fatalf("blue shadow grading = %d/%d/%d, want b > r", out.Pix[0], out.Pix[1], out.Pix[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorGradingLuminance(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 40, 40, 40, 255
|
||||
p := ColorParams{}
|
||||
p.Grading.Shadows = ColorWheel{Luminance: 100}
|
||||
out := applyColor(src, p)
|
||||
if out.Pix[0] <= 40 {
|
||||
t.Fatalf("shadow luminance did not brighten: %d", out.Pix[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorUniformRows(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 64, 64))
|
||||
for i := 0; i < len(src.Pix); i += 4 {
|
||||
src.Pix[i], src.Pix[i+1], src.Pix[i+2], src.Pix[i+3] = 40, 40, 40, 255
|
||||
}
|
||||
p := ColorParams{}
|
||||
p.Grading.Blending = 50
|
||||
p.Grading.Shadows = ColorWheel{Hue: 240, Saturation: 100}
|
||||
out := applyColor(src, p)
|
||||
wantR, wantG, wantB := out.Pix[0], out.Pix[1], out.Pix[2]
|
||||
for y := 0; y < 64; y++ {
|
||||
for x := 0; x < 64; x++ {
|
||||
i := y*out.Stride + x*4
|
||||
if out.Pix[i] != wantR || out.Pix[i+1] != wantG || out.Pix[i+2] != wantB {
|
||||
t.Fatalf("pixel (%d,%d) = %d/%d/%d, want %d/%d/%d", x, y, out.Pix[i], out.Pix[i+1], out.Pix[i+2], wantR, wantG, wantB)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorParallelMatchesSingle(t *testing.T) {
|
||||
makeImage := func() *image.NRGBA {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 32, 64))
|
||||
for y := 0; y < 64; y++ {
|
||||
for x := 0; x < 32; x++ {
|
||||
i := y*img.Stride + x*4
|
||||
img.Pix[i] = uint8(20 + y*3)
|
||||
img.Pix[i+1] = uint8(20 + y*2)
|
||||
img.Pix[i+2] = uint8(30 + y)
|
||||
img.Pix[i+3] = 255
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
p := ColorParams{}
|
||||
p.Grading.Blending = 50
|
||||
p.Grading.Shadows = ColorWheel{Hue: 240, Saturation: 100}
|
||||
|
||||
parallel := applyColor(makeImage(), p)
|
||||
single := makeImage()
|
||||
applyColorRows(single, 0, single.Rect.Dy(), newColorAdjuster(p))
|
||||
|
||||
for i := range parallel.Pix {
|
||||
if parallel.Pix[i] != single.Pix[i] {
|
||||
t.Fatalf("parallel differs at byte %d: %d != %d", i, parallel.Pix[i], single.Pix[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorKeepsTransparentPixels(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
src.Pix[0], src.Pix[1], src.Pix[2], src.Pix[3] = 10, 20, 30, 0
|
||||
out := applyColor(src, ColorParams{Saturation: 100, Temperature: 100})
|
||||
if out.Pix[0] != 10 || out.Pix[1] != 20 || out.Pix[2] != 30 || out.Pix[3] != 0 {
|
||||
t.Fatalf("transparent pixel changed: %v", out.Pix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEditParamsWithoutColorIsBackwardCompatible(t *testing.T) {
|
||||
params, err := ParseEditParams([]byte(`{"light":{"exposure":0.5}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if !params.Color.IsEmpty() {
|
||||
t.Fatalf("missing color must be empty: %+v", params.Color)
|
||||
}
|
||||
if params.IsEmpty() {
|
||||
t.Fatal("light edits must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyColorEmptyIsIdentity(t *testing.T) {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 3, 1))
|
||||
colors := [][3]uint8{{0, 0, 0}, {200, 50, 25}, {33, 210, 128}}
|
||||
for i, c := range colors {
|
||||
src.Pix[i*4] = c[0]
|
||||
src.Pix[i*4+1] = c[1]
|
||||
src.Pix[i*4+2] = c[2]
|
||||
src.Pix[i*4+3] = 255
|
||||
}
|
||||
before := make([]byte, len(src.Pix))
|
||||
copy(before, src.Pix)
|
||||
applyColor(src, ColorParams{})
|
||||
for i := range before {
|
||||
if before[i] != src.Pix[i] {
|
||||
t.Fatalf("empty color changed pixel %d: %d != %d", i, src.Pix[i], before[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,12 +124,13 @@ func (h *Handler) Download(c *gin.Context) {
|
||||
func (h *Handler) Edit(c *gin.Context) {
|
||||
var req struct {
|
||||
Light LightParams `json:"light"`
|
||||
Color ColorParams `json:"color"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "light params are required"))
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "edit params are required"))
|
||||
return
|
||||
}
|
||||
p, err := h.svc.EditPhoto(c.Request.Context(), c.Param("id"), auth.UserID(c), EditParams{Light: req.Light})
|
||||
p, err := h.svc.EditPhoto(c.Request.Context(), c.Param("id"), auth.UserID(c), EditParams{Light: req.Light, Color: req.Color})
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user