check / check (push) Canceled after 0s
The new package internal/imagerequest holds the request type, its two parts Source and Transform, the Format and Fit types, the dimension and fit checks, the /v1/image/ path parsing and the variant key; it imports no other package of pixa. imgcache.ParsedURL, imgcache.ImageRequest, signature.Request, imageprocessor.Request, the second Size, format and fit types and the conversions between them are gone. The image processor takes a Transform only. encurl keeps a private struct with the token's short CBOR field names. VariantKey moves with the function that makes it. Tests follow the moved and renamed code; the tests of the removed conversions go with them. Model: opus-5-5
279 lines
7.8 KiB
Go
279 lines
7.8 KiB
Go
package imageprocessor
|
|
|
|
import (
|
|
"bytes"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"math"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
)
|
|
|
|
// TestImageProcessor_FitCoverServesThinSource verifies that FitCover serves
|
|
// a thin source at the requested size. An 8192x1 source scaled to cover
|
|
// 8192x8192 is 8192 times as long as the output on its long side. The centre
|
|
// of an 8192x1 source with the proportions of 1x8192 is 1/8192 of a pixel
|
|
// wide. Each case is also turned on its side.
|
|
func TestImageProcessor_FitCoverServesThinSource(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
inputWidth, inputHeight int
|
|
size imagerequest.Size
|
|
}{
|
|
{"8192x1 at 8192x8192", 8192, 1, imagerequest.Size{Width: 8192, Height: 8192}},
|
|
{"8192x1 at 1x8192", 8192, 1, imagerequest.Size{Width: 1, Height: 8192}},
|
|
{"1x8192 at 8192x8192", 1, 8192, imagerequest.Size{Width: 8192, Height: 8192}},
|
|
{"1x8192 at 8192x1", 1, 8192, imagerequest.Size{Width: 8192, Height: 1}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
|
|
|
result, err := New(Params{}).Process(t.Context(),
|
|
bytes.NewReader(input),
|
|
&imagerequest.Transform{
|
|
Size: tt.size,
|
|
Format: imagerequest.FormatJPEG,
|
|
FitMode: imagerequest.FitCover,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Process() error = %v", err)
|
|
}
|
|
|
|
defer func() { _ = result.Content.Close() }()
|
|
|
|
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
|
|
|
if result.Width != tt.size.Width || result.Height != tt.size.Height {
|
|
t.Errorf("Process() made %dx%d, want %dx%d",
|
|
result.Width, result.Height, tt.size.Width, tt.size.Height)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// createBandsPNG creates a PNG of three equal bands, red, green and blue,
|
|
// side by side when it is wider than tall and top to bottom otherwise.
|
|
func createBandsPNG(t *testing.T, width, height int) []byte {
|
|
t.Helper()
|
|
|
|
bands := []color.RGBA{
|
|
{R: 255, A: 255},
|
|
{G: 255, A: 255},
|
|
{B: 255, A: 255},
|
|
}
|
|
|
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
|
for y := range height {
|
|
for x := range width {
|
|
band := 3 * y / height
|
|
if width > height {
|
|
band = 3 * x / width
|
|
}
|
|
|
|
img.Set(x, y, bands[band])
|
|
}
|
|
}
|
|
|
|
return encodePNG(t, img)
|
|
}
|
|
|
|
// TestImageProcessor_FitCoverCropsTheCentre verifies that FitCover keeps the
|
|
// centre of the source: a 300x100 source of red, green and blue bands side
|
|
// by side, or a 100x300 one with them top to bottom, comes out at 50x50 as
|
|
// its green band.
|
|
func TestImageProcessor_FitCoverCropsTheCentre(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
inputWidth, inputHeight int
|
|
}{
|
|
{"bands side by side", 300, 100},
|
|
{"bands top to bottom", 100, 300},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := createBandsPNG(t, tt.inputWidth, tt.inputHeight)
|
|
|
|
result, err := New(Params{}).Process(t.Context(),
|
|
bytes.NewReader(input),
|
|
&imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatPNG,
|
|
FitMode: imagerequest.FitCover,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Process() error = %v", err)
|
|
}
|
|
|
|
defer func() { _ = result.Content.Close() }()
|
|
|
|
output, err := png.Decode(result.Content)
|
|
if err != nil {
|
|
t.Fatalf("failed to decode output: %v", err)
|
|
}
|
|
|
|
bounds := output.Bounds()
|
|
t.Logf("Process() made %dx%d", bounds.Dx(), bounds.Dy())
|
|
|
|
if bounds.Dx() != 50 || bounds.Dy() != 50 {
|
|
t.Fatalf("Process() made %dx%d, want 50x50",
|
|
bounds.Dx(), bounds.Dy())
|
|
}
|
|
|
|
// Resampling may blend a little of the bands beside the
|
|
// green one into the output's edges.
|
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
|
r, g, b, _ := output.At(x, y).RGBA()
|
|
if r>>8 > 64 || g>>8 < 192 || b>>8 > 64 {
|
|
t.Fatalf("output pixel %d,%d is %d,%d,%d, want green",
|
|
x, y, r>>8, g>>8, b>>8)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The greys of createSquarePNG, and the grey halfway between them.
|
|
const (
|
|
backgroundGrey = 192
|
|
squareGrey = 64
|
|
halfwayGrey = (backgroundGrey + squareGrey) / 2
|
|
)
|
|
|
|
// createSquarePNG creates a grey PNG side pixels square with a darker grey
|
|
// square squareSide pixels wide in its centre.
|
|
func createSquarePNG(t *testing.T, side, squareSide int) []byte {
|
|
t.Helper()
|
|
|
|
first := (side - squareSide) / 2
|
|
last := first + squareSide - 1
|
|
|
|
img := image.NewGray(image.Rect(0, 0, side, side))
|
|
for y := range side {
|
|
for x := range side {
|
|
grey := uint8(backgroundGrey)
|
|
if x >= first && x <= last && y >= first && y <= last {
|
|
grey = squareGrey
|
|
}
|
|
|
|
img.SetGray(x, y, color.Gray{Y: grey})
|
|
}
|
|
}
|
|
|
|
return encodePNG(t, img)
|
|
}
|
|
|
|
// squareLength returns how long the square is along greys, the greys of a
|
|
// row or column of the output that crosses it: the distance between the
|
|
// first and the last point where greys passes halfwayGrey, or 0 when there
|
|
// is no such point. Resampling blurs the square's edges over several
|
|
// pixels, and each point is placed between the two pixels on either side
|
|
// of it in proportion to their greys, so the length is measured to a
|
|
// fraction of a pixel.
|
|
func squareLength(greys []float64) float64 {
|
|
first, last := -1.0, -1.0
|
|
|
|
for i := 1; i < len(greys); i++ {
|
|
before, after := greys[i-1], greys[i]
|
|
if (before > halfwayGrey) == (after > halfwayGrey) {
|
|
continue
|
|
}
|
|
|
|
point := float64(i-1) + (before-halfwayGrey)/(before-after)
|
|
if first < 0 {
|
|
first = point
|
|
}
|
|
|
|
last = point
|
|
}
|
|
|
|
return last - first
|
|
}
|
|
|
|
// TestImageProcessor_FitCoverKeepsProportions verifies that FitCover scales
|
|
// both sides of the source by the same amount: a square in the centre of
|
|
// the source comes out square. Its width and height are measured along the
|
|
// output's middle row and column.
|
|
func TestImageProcessor_FitCoverKeepsProportions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
side, squareSide int
|
|
size imagerequest.Size
|
|
}{
|
|
{"16x16 at 100x60", 16, 8, imagerequest.Size{Width: 100, Height: 60}},
|
|
{"16x16 at 60x100", 16, 8, imagerequest.Size{Width: 60, Height: 100}},
|
|
{"10x10 at 100x35", 10, 2, imagerequest.Size{Width: 100, Height: 35}},
|
|
{"10x10 at 35x100", 10, 2, imagerequest.Size{Width: 35, Height: 100}},
|
|
{"64x64 at 300x100", 64, 16, imagerequest.Size{Width: 300, Height: 100}},
|
|
{"64x64 at 100x300", 64, 16, imagerequest.Size{Width: 100, Height: 300}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := createSquarePNG(t, tt.side, tt.squareSide)
|
|
|
|
result, err := New(Params{}).Process(t.Context(),
|
|
bytes.NewReader(input),
|
|
&imagerequest.Transform{
|
|
Size: tt.size,
|
|
Format: imagerequest.FormatPNG,
|
|
FitMode: imagerequest.FitCover,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Process() error = %v", err)
|
|
}
|
|
|
|
defer func() { _ = result.Content.Close() }()
|
|
|
|
output, err := png.Decode(result.Content)
|
|
if err != nil {
|
|
t.Fatalf("failed to decode output: %v", err)
|
|
}
|
|
|
|
bounds := output.Bounds()
|
|
if bounds.Dx() != tt.size.Width || bounds.Dy() != tt.size.Height {
|
|
t.Fatalf("Process() made %dx%d, want %dx%d",
|
|
bounds.Dx(), bounds.Dy(), tt.size.Width, tt.size.Height)
|
|
}
|
|
|
|
row := make([]float64, bounds.Dx())
|
|
for x := range row {
|
|
r, _, _, _ := output.At(x, bounds.Dy()/2).RGBA()
|
|
row[x] = float64(r >> 8)
|
|
}
|
|
|
|
column := make([]float64, bounds.Dy())
|
|
for y := range column {
|
|
r, _, _, _ := output.At(bounds.Dx()/2, y).RGBA()
|
|
column[y] = float64(r >> 8)
|
|
}
|
|
|
|
width, height := squareLength(row), squareLength(column)
|
|
t.Logf("square is %.2f wide and %.2f high", width, height)
|
|
|
|
if width == 0 || math.Abs(width-height) > 0.5 {
|
|
t.Errorf("square is %.2f wide and %.2f high, want it square",
|
|
width, height)
|
|
}
|
|
})
|
|
}
|
|
}
|