chore: add resize tests

This commit is contained in:
Oleg Lobanov 2020-07-24 20:08:26 +02:00
parent aa78e3ab1f
commit cb8ac5ebf1
No known key found for this signature in database
GPG Key ID: 7CC64E41212621B0
5 changed files with 449 additions and 33 deletions

2
go.mod
View File

@ -25,11 +25,13 @@ require (
github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.6.1 github.com/spf13/viper v1.6.1
github.com/stretchr/testify v1.6.1
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
go.etcd.io/bbolt v1.3.3 go.etcd.io/bbolt v1.3.3
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8
golang.org/x/net v0.0.0-20200528225125-3c3fba18258b // indirect golang.org/x/net v0.0.0-20200528225125-3c3fba18258b // indirect
golang.org/x/sys v0.0.0-20200523222454-059865788121 // indirect golang.org/x/sys v0.0.0-20200523222454-059865788121 // indirect
golang.org/x/text v0.3.2 // indirect golang.org/x/text v0.3.2 // indirect

4
go.sum
View File

@ -206,6 +206,8 @@ github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
@ -318,4 +320,6 @@ gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@ -8,7 +8,6 @@ import (
"net/http" "net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/spf13/afero"
"github.com/filebrowser/filebrowser/v2/files" "github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/img" "github.com/filebrowser/filebrowser/v2/img"
@ -21,7 +20,7 @@ const (
type ImgService interface { type ImgService interface {
FormatFromExtension(ext string) (img.Format, error) FormatFromExtension(ext string) (img.Format, error)
Resize(ctx context.Context, file afero.File, width, height int, out io.Writer, options ...img.Option) error Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...img.Option) error
} }
func previewHandler(imgSvc ImgService, enableThumbnails, resizePreview bool) handleFunc { func previewHandler(imgSvc ImgService, enableThumbnails, resizePreview bool) handleFunc {

View File

@ -2,14 +2,15 @@
package img package img
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt"
"image"
"io" "io"
"path/filepath"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
"github.com/marusama/semaphore/v2" "github.com/marusama/semaphore/v2"
"github.com/spf13/afero"
) )
// ErrUnsupportedFormat means the given image format is not supported. // ErrUnsupportedFormat means the given image format is not supported.
@ -17,14 +18,12 @@ var ErrUnsupportedFormat = errors.New("unsupported image format")
// Service // Service
type Service struct { type Service struct {
lowPrioritySem semaphore.Semaphore sem semaphore.Semaphore
highPrioritySem semaphore.Semaphore
} }
func New(workers int) *Service { func New(workers int) *Service {
return &Service{ return &Service{
lowPrioritySem: semaphore.New(workers), sem: semaphore.New(workers),
highPrioritySem: semaphore.New(workers),
} }
} }
@ -75,7 +74,7 @@ func (x Quality) resampleFilter() imaging.ResampleFilter {
case QualityLow: case QualityLow:
return imaging.NearestNeighbor return imaging.NearestNeighbor
default: default:
return imaging.Linear return imaging.Box
} }
} }
@ -108,13 +107,19 @@ func (s *Service) FormatFromExtension(ext string) (Format, error) {
} }
type resizeConfig struct { type resizeConfig struct {
prioritized bool format Format
resizeMode ResizeMode resizeMode ResizeMode
quality Quality quality Quality
} }
type Option func(*resizeConfig) type Option func(*resizeConfig)
func WithFormat(format Format) Option {
return func(config *resizeConfig) {
config.format = format
}
}
func WithMode(mode ResizeMode) Option { func WithMode(mode ResizeMode) Option {
return func(config *resizeConfig) { return func(config *resizeConfig) {
config.resizeMode = mode config.resizeMode = mode
@ -127,14 +132,19 @@ func WithQuality(quality Quality) Option {
} }
} }
func WithHighPriority() Option { func (s *Service) Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...Option) error {
return func(config *resizeConfig) { if err := s.sem.Acquire(ctx, 1); err != nil {
config.prioritized = true return err
}
defer s.sem.Release(1)
format, wrappedReader, err := s.detectFormat(in)
if err != nil {
return err
} }
}
func (s *Service) Resize(ctx context.Context, file afero.File, width, height int, out io.Writer, options ...Option) error {
config := resizeConfig{ config := resizeConfig{
format: format,
resizeMode: ResizeModeFit, resizeMode: ResizeModeFit,
quality: QualityMedium, quality: QualityMedium,
} }
@ -142,21 +152,7 @@ func (s *Service) Resize(ctx context.Context, file afero.File, width, height int
option(&config) option(&config)
} }
sem := s.lowPrioritySem img, err := imaging.Decode(wrappedReader, imaging.AutoOrientation(true))
if config.prioritized {
sem = s.highPrioritySem
}
if err := sem.Acquire(ctx, 1); err != nil {
return err
}
defer sem.Release(1)
format, err := s.FormatFromExtension(filepath.Ext(file.Name()))
if err != nil {
return ErrUnsupportedFormat
}
img, err := imaging.Decode(file, imaging.AutoOrientation(true))
if err != nil { if err != nil {
return err return err
} }
@ -168,5 +164,22 @@ func (s *Service) Resize(ctx context.Context, file afero.File, width, height int
img = imaging.Fit(img, width, height, config.quality.resampleFilter()) img = imaging.Fit(img, width, height, config.quality.resampleFilter())
} }
return imaging.Encode(out, img, format.toImaging()) return imaging.Encode(out, img, config.format.toImaging())
}
func (s *Service) detectFormat(in io.Reader) (Format, io.Reader, error) {
buf := &bytes.Buffer{}
r := io.TeeReader(in, buf)
_, imgFormat, err := image.DecodeConfig(r)
if err != nil {
return 0, nil, fmt.Errorf("%s: %w", err.Error(), ErrUnsupportedFormat)
}
format, err := ParseFormat(imgFormat)
if err != nil {
return 0, nil, ErrUnsupportedFormat
}
return format, io.MultiReader(buf, in), nil
} }

398
img/service_test.go Normal file
View File

@ -0,0 +1,398 @@
package img
import (
"bytes"
"context"
"errors"
"image"
"image/gif"
"image/jpeg"
"image/png"
"io"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"golang.org/x/image/bmp"
"golang.org/x/image/tiff"
)
func TestService_Resize(t *testing.T) {
testCases := map[string]struct {
options []Option
width int
height int
source func(t *testing.T) afero.File
matcher func(t *testing.T, reader io.Reader)
wantErr bool
}{
"fill upscale": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 50, 20)
},
matcher: sizeMatcher(100, 100),
},
"fill downscale": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"fit upscale": {
options: []Option{WithMode(ResizeModeFit)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 50, 20)
},
matcher: sizeMatcher(50, 20),
},
"fit downscale": {
options: []Option{WithMode(ResizeModeFit)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: sizeMatcher(100, 75),
},
"keep original format": {
options: []Option{},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayPng(t, 200, 150)
},
matcher: formatMatcher(FormatPng),
},
"convert to jpeg": {
options: []Option{WithFormat(FormatJpeg)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: formatMatcher(FormatJpeg),
},
"convert to png": {
options: []Option{WithFormat(FormatPng)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: formatMatcher(FormatPng),
},
"convert to gif": {
options: []Option{WithFormat(FormatGif)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: formatMatcher(FormatGif),
},
"convert to tiff": {
options: []Option{WithFormat(FormatTiff)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: formatMatcher(FormatTiff),
},
"convert to bmp": {
options: []Option{WithFormat(FormatBmp)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: formatMatcher(FormatBmp),
},
"convert to unknown": {
options: []Option{WithFormat(Format(-1))},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: formatMatcher(FormatJpeg),
},
"resize png": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayPng(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"resize gif": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayGif(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"resize tiff": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayTiff(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"resize bmp": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayBmp(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"resize with high quality": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityHigh)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"resize with medium quality": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityMedium)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"resize with low quality": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"resize with unknown quality": {
options: []Option{WithMode(ResizeModeFill), WithQuality(Quality(-1))},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"broken file": {
options: []Option{WithMode(ResizeModeFit)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
fs := afero.NewMemMapFs()
file, err := fs.Create("image.jpg")
require.NoError(t, err)
_, err = file.WriteString("this is not an image")
require.NoError(t, err)
return file
},
wantErr: true,
},
}
for name, test := range testCases {
t.Run(name, func(t *testing.T) {
svc := New(1)
source := test.source(t)
defer source.Close()
buf := &bytes.Buffer{}
err := svc.Resize(context.Background(), source, test.width, test.height, buf, test.options...)
if (err != nil) != test.wantErr {
t.Fatalf("GetMarketSpecs() error = %v, wantErr %v", err, test.wantErr)
}
if err != nil {
return
}
test.matcher(t, buf)
})
}
}
func sizeMatcher(width, height int) func(t *testing.T, reader io.Reader) {
return func(t *testing.T, reader io.Reader) {
resizedImg, _, err := image.Decode(reader)
require.NoError(t, err)
require.Equal(t, width, resizedImg.Bounds().Dx())
require.Equal(t, height, resizedImg.Bounds().Dy())
}
}
func formatMatcher(format Format) func(t *testing.T, reader io.Reader) {
return func(t *testing.T, reader io.Reader) {
_, decodedFormat, err := image.DecodeConfig(reader)
require.NoError(t, err)
require.Equal(t, format.String(), decodedFormat)
}
}
func newGrayJpeg(t *testing.T, width, height int) afero.File {
fs := afero.NewMemMapFs()
file, err := fs.Create("image.jpg")
require.NoError(t, err)
img := image.NewGray(image.Rect(0, 0, width, height))
err = jpeg.Encode(file, img, &jpeg.Options{Quality: 90})
require.NoError(t, err)
_, err = file.Seek(0, io.SeekStart)
require.NoError(t, err)
return file
}
func newGrayPng(t *testing.T, width, height int) afero.File {
fs := afero.NewMemMapFs()
file, err := fs.Create("image.png")
require.NoError(t, err)
img := image.NewGray(image.Rect(0, 0, width, height))
err = png.Encode(file, img)
require.NoError(t, err)
_, err = file.Seek(0, io.SeekStart)
require.NoError(t, err)
return file
}
func newGrayGif(t *testing.T, width, height int) afero.File {
fs := afero.NewMemMapFs()
file, err := fs.Create("image.gif")
require.NoError(t, err)
img := image.NewGray(image.Rect(0, 0, width, height))
err = gif.Encode(file, img, nil)
require.NoError(t, err)
_, err = file.Seek(0, io.SeekStart)
require.NoError(t, err)
return file
}
func newGrayTiff(t *testing.T, width, height int) afero.File {
fs := afero.NewMemMapFs()
file, err := fs.Create("image.tiff")
require.NoError(t, err)
img := image.NewGray(image.Rect(0, 0, width, height))
err = tiff.Encode(file, img, nil)
require.NoError(t, err)
_, err = file.Seek(0, io.SeekStart)
require.NoError(t, err)
return file
}
func newGrayBmp(t *testing.T, width, height int) afero.File {
fs := afero.NewMemMapFs()
file, err := fs.Create("image.bmp")
require.NoError(t, err)
img := image.NewGray(image.Rect(0, 0, width, height))
err = bmp.Encode(file, img)
require.NoError(t, err)
_, err = file.Seek(0, io.SeekStart)
require.NoError(t, err)
return file
}
func TestService_FormatFromExtension(t *testing.T) {
testCases := map[string]struct {
ext string
want Format
wantErr error
}{
"jpg": {
ext: ".jpg",
want: FormatJpeg,
},
"jpeg": {
ext: ".jpeg",
want: FormatJpeg,
},
"png": {
ext: ".png",
want: FormatPng,
},
"gif": {
ext: ".gif",
want: FormatGif,
},
"tiff": {
ext: ".tiff",
want: FormatTiff,
},
"bmp": {
ext: ".bmp",
want: FormatBmp,
},
"unknown": {
ext: ".mov",
wantErr: ErrUnsupportedFormat,
},
}
for name, test := range testCases {
t.Run(name, func(t *testing.T) {
svc := New(1)
got, err := svc.FormatFromExtension(test.ext)
require.Truef(t, errors.Is(err, test.wantErr), "error = %v, wantErr %v", err, test.wantErr)
if err != nil {
return
}
require.Equal(t, test.want, got)
})
}
}