feat(photos): инструмент «Цвет» с WebGL-превью и пипеткой
- неразрушающие правки цвета: баланс белого, вибрация/насыщенность, цветовой микшер, колористика и поканальная коррекция - серверный рендер цвета в Go и паритетная реализация в WASM - живое превью на WebGL2 (кадр считает GPU), CPU/WASM-фолбэк - пипетка баланса белого по нейтральной точке - новые контролы: слайдеры с градиентами, цветовые колёса, двойной клик по контролу сбрасывает значение - тесты: параметры, рендер, паритет JS/WASM, e2e сохранения и сброса
This commit is contained in:
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user