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
255 lines
7.4 KiB
Go
255 lines
7.4 KiB
Go
package imageprocessor
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/davidbyttow/govips/v2/vips"
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
)
|
|
|
|
// TestImageProcessor_EncodeJPEGXL converts a JPEG to a smaller JPEG XL and
|
|
// checks the content type, and the format and size the output loads as.
|
|
func TestImageProcessor_EncodeJPEGXL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transform := &imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 100, Height: 75},
|
|
Format: imagerequest.FormatJXL,
|
|
}
|
|
|
|
result, err := New(Params{}).Process(
|
|
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), transform,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Process() error = %v", err)
|
|
}
|
|
|
|
defer func() { _ = result.Content.Close() }()
|
|
|
|
if result.ContentType != "image/jxl" {
|
|
t.Errorf("ContentType = %q, want image/jxl", result.ContentType)
|
|
}
|
|
|
|
data, err := io.ReadAll(result.Content)
|
|
if err != nil {
|
|
t.Fatalf("failed to read result: %v", err)
|
|
}
|
|
|
|
output, err := vips.NewImageFromBuffer(data)
|
|
if err != nil {
|
|
t.Fatalf("failed to load the output: %v", err)
|
|
}
|
|
|
|
defer output.Close()
|
|
|
|
if output.Format() != vips.ImageTypeJXL {
|
|
t.Errorf("output format = %s, want jxl", vips.ImageTypes[output.Format()])
|
|
}
|
|
|
|
if output.Width() != 100 || output.Height() != 75 {
|
|
t.Errorf("output size = %dx%d, want 100x75", output.Width(), output.Height())
|
|
}
|
|
}
|
|
|
|
// TestImageProcessor_JPEGXLQuality verifies that the quality reaches the JPEG
|
|
// XL encoder: the same image comes out smaller at quality 30 than at 90.
|
|
func TestImageProcessor_JPEGXLQuality(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := createTestJPEG(t, 400, 300)
|
|
outputBytes := make(map[int]int64)
|
|
|
|
for _, quality := range []int{30, 90} {
|
|
result, err := New(Params{}).Process(t.Context(),
|
|
bytes.NewReader(input),
|
|
&imagerequest.Transform{Format: imagerequest.FormatJXL, Quality: quality})
|
|
if err != nil {
|
|
t.Fatalf("Process() at quality %d error = %v", quality, err)
|
|
}
|
|
|
|
_ = result.Content.Close()
|
|
outputBytes[quality] = result.ContentLength
|
|
}
|
|
|
|
if outputBytes[30] >= outputBytes[90] {
|
|
t.Errorf("%d bytes at quality 30, %d at 90, want fewer at 30",
|
|
outputBytes[30], outputBytes[90])
|
|
}
|
|
}
|
|
|
|
// TestImageProcessor_JPEGXLDropsSourceEXIF verifies that none of the source's
|
|
// EXIF reaches a JPEG XL output. libvips 8.16 and later write an EXIF block of
|
|
// their own into JPEG XL (orientation, resolution, size, colour space and
|
|
// fixed defaults), and govips cannot ask them to leave it out, so the test
|
|
// looks for the source's fields rather than for no EXIF at all.
|
|
func TestImageProcessor_JPEGXLDropsSourceEXIF(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input, err := os.ReadFile("testdata/gps-exif.jpg")
|
|
if err != nil {
|
|
t.Fatalf("failed to read test JPEG: %v", err)
|
|
}
|
|
|
|
exif := processAndDecode(t, input,
|
|
&imagerequest.Transform{Format: imagerequest.FormatJXL}).GetExif()
|
|
|
|
for _, field := range []string{
|
|
"exif-ifd0-Make", "exif-ifd0-Model", "exif-ifd2-BodySerialNumber",
|
|
"exif-ifd2-DateTimeOriginal", "exif-ifd3-GPSLatitude",
|
|
"exif-ifd3-GPSLongitude",
|
|
} {
|
|
if value, found := exif[field]; found {
|
|
t.Errorf("output has %s: %s", field, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestImageProcessor_JPEGXLDropsSourceResolution verifies that the source's
|
|
// resolution does not reach the EXIF block libvips 8.16 and later write into
|
|
// JPEG XL. A JPEG XL image holds its resolution in that block alone, so the
|
|
// resolution the output loads with is the block's.
|
|
func TestImageProcessor_JPEGXLDropsSourceResolution(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// dpi-300.jpg is a flat 8x8 grey image with a resolution of 300 dpi.
|
|
input, err := os.ReadFile("testdata/dpi-300.jpg")
|
|
if err != nil {
|
|
t.Fatalf("failed to read test JPEG: %v", err)
|
|
}
|
|
|
|
output := processAndDecode(t, input,
|
|
&imagerequest.Transform{Format: imagerequest.FormatJXL})
|
|
|
|
// libvips gives the resolution in pixels per millimetre.
|
|
xDPI := math.Round(output.ResX() * 25.4)
|
|
yDPI := math.Round(output.ResY() * 25.4)
|
|
|
|
if xDPI == 300 || yDPI == 300 {
|
|
t.Errorf("output resolution = %vx%v dpi, the source's", xDPI, yDPI)
|
|
}
|
|
}
|
|
|
|
// TestImageProcessor_JPEGXLAppliesEXIFOrientation verifies that a JPEG XL
|
|
// output is turned upright, as TestImageProcessor_AppliesEXIFOrientation does
|
|
// for PNG.
|
|
func TestImageProcessor_JPEGXLAppliesEXIFOrientation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// orientation-6.jpg is stored 16x8, red on the left and blue on the
|
|
// right, with EXIF orientation 6 (turn 90 degrees clockwise to view).
|
|
// Upright it is 8x16, red on top and blue below.
|
|
input, err := os.ReadFile("testdata/orientation-6.jpg")
|
|
if err != nil {
|
|
t.Fatalf("failed to read test JPEG: %v", err)
|
|
}
|
|
|
|
output := processAndDecode(t, input,
|
|
&imagerequest.Transform{Format: imagerequest.FormatJXL})
|
|
|
|
if output.Width() != 8 || output.Height() != 16 {
|
|
t.Fatalf("output is %dx%d, want 8x16", output.Width(), output.Height())
|
|
}
|
|
|
|
top, err := output.GetPoint(4, 0)
|
|
if err != nil {
|
|
t.Fatalf("GetPoint() error = %v", err)
|
|
}
|
|
|
|
bottom, err := output.GetPoint(4, 15)
|
|
if err != nil {
|
|
t.Fatalf("GetPoint() error = %v", err)
|
|
}
|
|
|
|
if top[0] <= top[2] || bottom[2] <= bottom[0] {
|
|
t.Errorf("top pixel = %v, bottom pixel = %v, want red above blue",
|
|
top, bottom)
|
|
}
|
|
}
|
|
|
|
// TestImageProcessor_JPEGXLConvertsWideGamutToSRGB verifies that a JPEG XL
|
|
// output is converted to sRGB, as TestImageProcessor_ConvertsWideGamutToSRGB
|
|
// does for PNG. It does not check for an ICC profile, as libvips reports one
|
|
// for every JPEG XL image it loads.
|
|
func TestImageProcessor_JPEGXLConvertsWideGamutToSRGB(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// display-p3.jpg is a flat 8x8 image with the Display P3 profile
|
|
// embedded, filled with Display P3 (234, 51, 35), which is sRGB red.
|
|
input, err := os.ReadFile("testdata/display-p3.jpg")
|
|
if err != nil {
|
|
t.Fatalf("failed to read test JPEG: %v", err)
|
|
}
|
|
|
|
output := processAndDecode(t, input,
|
|
&imagerequest.Transform{Format: imagerequest.FormatJXL})
|
|
|
|
pixel, err := output.GetPoint(4, 4)
|
|
if err != nil {
|
|
t.Fatalf("GetPoint() error = %v", err)
|
|
}
|
|
|
|
want := []float64{255, 0, 0}
|
|
for i := range want {
|
|
if math.Abs(pixel[i]-want[i]) > 5 {
|
|
t.Fatalf("pixel = %v, want within 5 of %v", pixel, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestImageProcessor_JPEGXLFromCMYK verifies that a CMYK JPEG with no ICC
|
|
// profile can be served as JPEG XL, in sRGB.
|
|
func TestImageProcessor_JPEGXLFromCMYK(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// cmyk.jpg is a flat 8x8 CMYK image with no ICC profile, filled with
|
|
// full cyan and no magenta, yellow or black.
|
|
input, err := os.ReadFile("testdata/cmyk.jpg")
|
|
if err != nil {
|
|
t.Fatalf("failed to read test JPEG: %v", err)
|
|
}
|
|
|
|
output := processAndDecode(t, input,
|
|
&imagerequest.Transform{Format: imagerequest.FormatJXL})
|
|
|
|
if output.Bands() != 3 {
|
|
t.Fatalf("output has %d bands, want 3", output.Bands())
|
|
}
|
|
|
|
pixel, err := output.GetPoint(4, 4)
|
|
if err != nil {
|
|
t.Fatalf("GetPoint() error = %v", err)
|
|
}
|
|
|
|
// Cyan in sRGB: little red, much green and blue.
|
|
if pixel[0] > 50 || pixel[1] < 100 || pixel[2] < 200 {
|
|
t.Errorf("pixel = %v, want cyan", pixel)
|
|
}
|
|
}
|
|
|
|
// TestJXLDistance verifies the JPEG XL distance for a few qualities: 90 is
|
|
// distance 1, the encoder's own default, and 100 stays lossy.
|
|
func TestJXLDistance(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
quality int
|
|
want float64
|
|
}{
|
|
{quality: 100, want: 0.1},
|
|
{quality: 90, want: 1},
|
|
{quality: 30, want: 6.4},
|
|
{quality: 20, want: 9.0667},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := jxlDistance(tt.quality)
|
|
if math.Abs(got-tt.want) > 0.0001 {
|
|
t.Errorf("jxlDistance(%d) = %v, want %v", tt.quality, got, tt.want)
|
|
}
|
|
}
|
|
}
|