Files
ds-galo/backend/internal/photos/edit_test.go
T
iu c953008a86 feat(photos): добавить эффекты и настройки панели редактора
- текстура, чёткость, дымка, виньетка и зерно в неразрушающем редакторе
- живой WebGL2-предпросмотр эффектов и CPU-фолбэк
- секции редактора свёрнуты по умолчанию, состояние в localStorage
- вкладка «Редактирование» доступна только из просмотрщика
2026-10-24 22:57:30 +03:00

441 lines
13 KiB
Go

package photos
import (
"bytes"
"image"
"image/png"
"math"
"testing"
)
func TestParseEditParamsClampsValues(t *testing.T) {
raw := []byte(`{"light":{"exposure":99,"contrast":-500,"highlights":10,"curve":{"rgb":[{"x":2,"y":-1},{"x":0.5,"y":0.5}]}}}`)
params, err := ParseEditParams(raw)
if err != nil {
t.Fatalf("parse: %v", err)
}
if params.Light.Exposure != MaxExposureStops {
t.Fatalf("exposure = %v, want %v", params.Light.Exposure, MaxExposureStops)
}
if params.Light.Contrast != -MaxPercent {
t.Fatalf("contrast = %v, want %v", params.Light.Contrast, -MaxPercent)
}
if len(params.Light.Curve.RGB) != 2 {
t.Fatalf("curve points = %d, want 2", len(params.Light.Curve.RGB))
}
// Точки сортируются по X, а значения зажимаются в диапазон 0..1.
first := params.Light.Curve.RGB[0]
if first.X != 0.5 || first.Y != 0.5 {
t.Fatalf("first curve point = %+v, want {0.5 0.5}", first)
}
second := params.Light.Curve.RGB[1]
if second.X != 1 || second.Y != 0 {
t.Fatalf("second curve point = %+v, want {1 0}", second)
}
}
func TestEditRolesCoverTheEditedVersion(t *testing.T) {
roles := EditRoles()
if len(roles) != 4 {
t.Fatalf("edit roles = %v, want four", roles)
}
for _, role := range roles {
if !IsEditRole(role) {
t.Fatalf("role %q must be an edit role", role)
}
}
for _, role := range []string{RoleOriginal, RoleThumb, RoleMedium, RolePreview, RolePlayback} {
if IsEditRole(role) {
t.Fatalf("role %q must not be an edit role", role)
}
}
}
func TestEditParamsIsEmpty(t *testing.T) {
var zero EditParams
if !zero.IsEmpty() {
t.Fatal("zero params must be empty")
}
zero.Light.Exposure = 0.1
if zero.IsEmpty() {
t.Fatal("params with exposure must not be empty")
}
curved := EditParams{}
curved.Light.Curve.RGB = []CurvePoint{{X: 0, Y: 0}, {X: 1, Y: 0.5}}
if curved.IsEmpty() {
t.Fatal("params with a bent curve must not be empty")
}
linear := EditParams{}
linear.Light.Curve.RGB = []CurvePoint{{X: 0, Y: 0}, {X: 1, Y: 1}}
if !linear.IsEmpty() {
t.Fatal("a linear curve must count as empty")
}
}
func TestCurveLUTIdentity(t *testing.T) {
lut := curveLUT(nil)
for i := 0; i < 256; i++ {
if int(lut[i]) != i {
t.Fatalf("lut[%d] = %d, want %d", i, lut[i], i)
}
}
}
func TestCurveLUTSteepens(t *testing.T) {
lut := curveLUT([]CurvePoint{{X: 0, Y: 0}, {X: 0.5, Y: 0.25}, {X: 1, Y: 1}})
if lut[0] != 0 || lut[255] != 255 {
t.Fatalf("curve ends = %d..%d, want 0..255", lut[0], lut[255])
}
if lut[128] >= 128 {
t.Fatalf("lut[128] = %d, want a darker midtone", lut[128])
}
}
// The client builds the same 256-entry LUT (frontend/src/lib/edit.ts). The
// hard-coded samples keep the Go and TypeScript implementations in sync.
func TestCurveLUTMatchesClient(t *testing.T) {
samples := []int{0, 32, 64, 128, 192, 224, 255}
cases := []struct {
name string
points []CurvePoint
want []int
}{
{"linear", []CurvePoint{{0, 0}, {1, 1}}, []int{0, 32, 64, 128, 192, 224, 255}},
{
"medium",
[]CurvePoint{{0, 0}, {0.25, 0.2}, {0.5, 0.5}, {0.75, 0.8}, {1, 1}},
[]int{0, 23, 51, 128, 205, 233, 255},
},
{
"strong",
[]CurvePoint{{0, 0}, {0.2, 0.12}, {0.5, 0.5}, {0.8, 0.88}, {1, 1}},
[]int{0, 17, 43, 128, 213, 239, 255},
},
{
"inner",
[]CurvePoint{{0.1, 0.05}, {0.4, 0.3}, {0.9, 0.95}},
[]int{13, 17, 42, 104, 186, 234, 242},
},
}
for _, tc := range cases {
lut := curveLUT(tc.points)
for i, index := range samples {
if int(lut[index]) != tc.want[i] {
t.Fatalf("%s lut[%d] = %d, want %d", tc.name, index, lut[index], tc.want[i])
}
}
}
}
func TestCurveLUTIsMonotone(t *testing.T) {
for _, points := range [][]CurvePoint{
{{0, 0}, {0.25, 0.2}, {0.5, 0.5}, {0.75, 0.8}, {1, 1}},
{{0, 0}, {0.2, 0.12}, {0.5, 0.5}, {0.8, 0.88}, {1, 1}},
} {
lut := curveLUT(points)
for i := 1; i < len(lut); i++ {
if lut[i] < lut[i-1] {
t.Fatalf("lut[%d] = %d < lut[%d] = %d", i, lut[i], i-1, lut[i-1])
}
}
}
}
func TestCurveLUTFlatOutsideEndpoints(t *testing.T) {
lut := curveLUT([]CurvePoint{{0.1, 0.05}, {0.4, 0.3}, {0.9, 0.95}})
for i := 0; i <= int(math.Floor(0.1*255)); i++ {
if lut[i] != 13 {
t.Fatalf("lut[%d] = %d, want flat 13", i, lut[i])
}
}
for i := 230; i < len(lut); i++ {
if lut[i] != 242 {
t.Fatalf("lut[%d] = %d, want flat 242", i, lut[i])
}
}
}
func TestApplyLightBrightensAndKeepsSource(t *testing.T) {
src := image.NewNRGBA(image.Rect(0, 0, 2, 2))
for i := range src.Pix {
src.Pix[i] = 100
}
src.Pix[3] = 255
before := make([]byte, len(src.Pix))
copy(before, src.Pix)
edited := applyLight(src, LightParams{Exposure: 1})
if edited.Pix[0] <= before[0] {
t.Fatalf("exposure did not brighten: %d <= %d", edited.Pix[0], before[0])
}
for i := range src.Pix {
if src.Pix[i] != before[i] {
t.Fatalf("source pixel %d changed: %d != %d", i, src.Pix[i], before[i])
}
}
}
func TestApplyLightContrast(t *testing.T) {
src := image.NewNRGBA(image.Rect(0, 0, 1, 1))
for i := range src.Pix {
src.Pix[i] = 200
}
src.Pix[3] = 255
edited := applyLight(src, LightParams{Contrast: 50})
if edited.Pix[0] <= 200 {
t.Fatalf("contrast did not push the bright tone up: %d", edited.Pix[0])
}
// Контраст работает вокруг средней точки: тёмные тона уходят вниз.
dark := image.NewNRGBA(image.Rect(0, 0, 1, 1))
for i := range dark.Pix {
dark.Pix[i] = 60
}
dark.Pix[3] = 255
editedDark := applyLight(dark, LightParams{Contrast: 50})
if editedDark.Pix[0] >= 60 {
t.Fatalf("contrast did not push the dark tone down: %d", editedDark.Pix[0])
}
}
func TestBuildEditedVariants(t *testing.T) {
src := image.NewNRGBA(image.Rect(0, 0, 400, 300))
for y := 0; y < 300; y++ {
for x := 0; x < 400; x++ {
i := y*src.Stride + x*4
src.Pix[i] = uint8(x % 256)
src.Pix[i+1] = uint8(y % 256)
src.Pix[i+2] = 128
src.Pix[i+3] = 255
}
}
edited, err := decodeAndAdjust(mustEncodePNG(t, src), EditParams{Light: LightParams{Exposure: 0.5}}, editSeed("test-photo"))
if err != nil {
t.Fatalf("decode and adjust: %v", err)
}
thumb, medium, preview, err := encodeEditedVariants(edited)
if err != nil {
t.Fatalf("encode variants: %v", err)
}
full, err := encodeEdited(edited)
if err != nil {
t.Fatalf("encode full: %v", err)
}
if len(thumb) == 0 || len(medium) == 0 || len(preview) == 0 || len(full) == 0 {
t.Fatalf("empty variants: %d %d %d %d", len(thumb), len(medium), len(preview), len(full))
}
}
func TestAdjustChannelClamps(t *testing.T) {
lut := curveLUT(nil)
if v := adjustChannel(255, LightParams{Exposure: 5}, &lut, &lut); v != 255 {
t.Fatalf("value = %v, want 255", v)
}
if v := adjustChannel(0, LightParams{Exposure: -5}, &lut, &lut); v != 0 {
t.Fatalf("value = %v, want 0", v)
}
if v := adjustChannel(128, LightParams{Contrast: 0}, &lut, &lut); math.Abs(v-128) > 0.001 {
t.Fatalf("value = %v, want 128", v)
}
}
func mustEncodePNG(t *testing.T, img image.Image) []byte {
t.Helper()
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
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])
}
}
}