diff --git a/backend/internal/e2e/color_edit_test.go b/backend/internal/e2e/color_edit_test.go new file mode 100644 index 0000000..14de3dd --- /dev/null +++ b/backend/internal/e2e/color_edit_test.go @@ -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) + } +} diff --git a/backend/internal/photos/edit.go b/backend/internal/photos/edit.go index 2d673d0..35971d1 100644 --- a/backend/internal/photos/edit.go +++ b/backend/internal/photos/edit.go @@ -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) +} diff --git a/backend/internal/photos/edit_render.go b/backend/internal/photos/edit_render.go index 5849c0f..c3363d2 100644 --- a/backend/internal/photos/edit_render.go +++ b/backend/internal/photos/edit_render.go @@ -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 diff --git a/backend/internal/photos/edit_test.go b/backend/internal/photos/edit_test.go index 9134567..3a9c5d7 100644 --- a/backend/internal/photos/edit_test.go +++ b/backend/internal/photos/edit_test.go @@ -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]) + } + } +} diff --git a/backend/internal/photos/handlers.go b/backend/internal/photos/handlers.go index c4f4dc6..80cf050 100644 --- a/backend/internal/photos/handlers.go +++ b/backend/internal/photos/handlers.go @@ -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 diff --git a/frontend/src/lib/components/ColorWheel.svelte b/frontend/src/lib/components/ColorWheel.svelte new file mode 100644 index 0000000..3979c16 --- /dev/null +++ b/frontend/src/lib/components/ColorWheel.svelte @@ -0,0 +1,87 @@ + + +
+
+ +
+ {#if label}{label}{/if} +
diff --git a/frontend/src/lib/components/EditSlider.svelte b/frontend/src/lib/components/EditSlider.svelte new file mode 100644 index 0000000..1fe0063 --- /dev/null +++ b/frontend/src/lib/components/EditSlider.svelte @@ -0,0 +1,109 @@ + + +
+
+ + {display} +
+ +
+ + diff --git a/frontend/src/lib/components/PhotoEditPanel.svelte b/frontend/src/lib/components/PhotoEditPanel.svelte index 0113e65..4c86ca6 100644 --- a/frontend/src/lib/components/PhotoEditPanel.svelte +++ b/frontend/src/lib/components/PhotoEditPanel.svelte @@ -1,37 +1,58 @@