feature(thumbnails): add the ability to define custom image processors (#7409)

* feature(thumbnails): add the ability to define custom image processors

* fix(ci): add exported member comment

* docs(thumbnails): mention processors in readme

* fix: codacy and code review feedback

* fix: thumbnail readme markdown

Co-authored-by: Martin <github@diemattels.at>

---------

Co-authored-by: Martin <github@diemattels.at>
This commit is contained in:
Florian Schade
2023-10-17 09:44:44 +02:00
committed by GitHub
co-authored by Martin
parent 224f439e08
commit 9abcd8a7f3
18 changed files with 458 additions and 97 deletions
@@ -16,6 +16,10 @@ import (
"github.com/cs3org/reva/v2/pkg/storagespace"
"github.com/cs3org/reva/v2/pkg/utils"
"github.com/golang-jwt/jwt/v4"
"github.com/pkg/errors"
merrors "go-micro.dev/v4/errors"
"google.golang.org/grpc/metadata"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
thumbnailssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/thumbnails/v0"
"github.com/owncloud/ocis/v2/services/thumbnails/pkg/preprocessor"
@@ -23,9 +27,6 @@ import (
tjwt "github.com/owncloud/ocis/v2/services/thumbnails/pkg/service/jwt"
"github.com/owncloud/ocis/v2/services/thumbnails/pkg/thumbnail"
"github.com/owncloud/ocis/v2/services/thumbnails/pkg/thumbnail/imgsource"
"github.com/pkg/errors"
merrors "go-micro.dev/v4/errors"
"google.golang.org/grpc/metadata"
)
// NewService returns a service implementation for Service.
@@ -124,7 +125,7 @@ func (g Thumbnail) handleCS3Source(ctx context.Context, req *thumbnailssvc.GetTh
if tType == "" {
tType = req.GetThumbnailType().String()
}
tr, err := thumbnail.PrepareRequest(int(req.Width), int(req.Height), tType, sRes.GetInfo().GetChecksum().GetSum())
tr, err := thumbnail.PrepareRequest(int(req.Width), int(req.Height), tType, sRes.GetInfo().GetChecksum().GetSum(), req.Processor)
if err != nil {
return "", merrors.BadRequest(g.serviceID, err.Error())
}
@@ -207,7 +208,7 @@ func (g Thumbnail) handleWebdavSource(ctx context.Context, req *thumbnailssvc.Ge
if tType == "" {
tType = req.GetThumbnailType().String()
}
tr, err := thumbnail.PrepareRequest(int(req.Width), int(req.Height), tType, sRes.GetInfo().GetChecksum().GetSum())
tr, err := thumbnail.PrepareRequest(int(req.Width), int(req.Height), tType, sRes.GetInfo().GetChecksum().GetSum(), req.Processor)
if err != nil {
return "", merrors.BadRequest(g.serviceID, err.Error())
}
@@ -114,8 +114,8 @@ func EncoderForType(fileType string) (Encoder, error) {
}
// GetExtForMime return the supported extension by mime
func GetExtForMime(mime string) string {
ext := strings.TrimPrefix(strings.TrimSpace(strings.ToLower(mime)), "image/")
func GetExtForMime(fileType string) string {
ext := strings.TrimPrefix(strings.TrimSpace(strings.ToLower(fileType)), "image/")
switch ext {
case typeJpg, typeJpeg, typePng, typeGif:
return ext
+13 -16
View File
@@ -1,7 +1,6 @@
package thumbnail
import (
"errors"
"image"
"image/color"
"image/draw"
@@ -11,36 +10,34 @@ import (
"github.com/disintegration/imaging"
)
var (
// ErrInvalidType represents the error when a type can't be encoded.
ErrInvalidType2 = errors.New("can't encode this type")
// ErrNoGeneratorForType represents the error when no generator could be found for a type.
ErrNoGeneratorForType = errors.New("no generator for this type found")
)
// Generator generates a web friendly file version.
type Generator interface {
GenerateThumbnail(image.Rectangle, interface{}) (interface{}, error)
Generate(image.Rectangle, interface{}, Processor) (interface{}, error)
}
// SimpleGenerator is the default image generator and is used for all image types expect gif.
type SimpleGenerator struct{}
func (g SimpleGenerator) GenerateThumbnail(size image.Rectangle, img interface{}) (interface{}, error) {
// Generate generates a alternative image version.
func (g SimpleGenerator) Generate(size image.Rectangle, img interface{}, processor Processor) (interface{}, error) {
m, ok := img.(image.Image)
if !ok {
return nil, ErrInvalidType2
return nil, ErrInvalidType
}
return imaging.Thumbnail(m, size.Dx(), size.Dy(), imaging.Lanczos), nil
return processor.Process(m, size.Dx(), size.Dy(), imaging.Lanczos), nil
}
// GifGenerator is used to create a web friendly version of the provided gif image.
type GifGenerator struct{}
func (g GifGenerator) GenerateThumbnail(size image.Rectangle, img interface{}) (interface{}, error) {
// Generate generates a alternative gif version.
func (g GifGenerator) Generate(size image.Rectangle, img interface{}, processor Processor) (interface{}, error) {
// Code inspired by https://github.com/willnorris/gifresize/blob/db93a7e1dcb1c279f7eeb99cc6d90b9e2e23e871/gifresize.go
m, ok := img.(*gif.GIF)
if !ok {
return nil, ErrInvalidType2
return nil, ErrInvalidType
}
// Create a new RGBA image to hold the incremental frames.
srcX, srcY := m.Config.Width, m.Config.Height
@@ -51,8 +48,8 @@ func (g GifGenerator) GenerateThumbnail(size image.Rectangle, img interface{}) (
bounds := frame.Bounds()
prev := tmp
draw.Draw(tmp, bounds, frame, bounds.Min, draw.Over)
scaled := imaging.Resize(tmp, size.Dx(), size.Dy(), imaging.Lanczos)
m.Image[i] = g.imageToPaletted(scaled, frame.Palette)
processed := processor.Process(tmp, size.Dx(), size.Dy(), imaging.Lanczos)
m.Image[i] = g.imageToPaletted(processed, frame.Palette)
switch m.Disposal[i] {
case gif.DisposalBackground:
@@ -0,0 +1,51 @@
package thumbnail
import (
"image"
"strings"
"github.com/disintegration/imaging"
)
// Processor processes the thumbnail by applying different transformations to it.
type Processor interface {
ID() string
Process(img image.Image, width, height int, filter imaging.ResampleFilter) *image.NRGBA
}
// DefinableProcessor is the most simple processor, it holds a replaceable image converter function.
type DefinableProcessor struct {
Slug string
Converter func(img image.Image, width, height int, filter imaging.ResampleFilter) *image.NRGBA
}
// ID returns the processor identification.
func (p DefinableProcessor) ID() string { return p.Slug }
// Process transforms the given image.
func (p DefinableProcessor) Process(img image.Image, width, height int, filter imaging.ResampleFilter) *image.NRGBA {
return p.Converter(img, width, height, filter)
}
// ProcessorFor returns a matching Processor
func ProcessorFor(id, fileType string) (DefinableProcessor, error) {
switch strings.ToLower(id) {
case "fit":
return DefinableProcessor{Slug: strings.ToLower(id), Converter: imaging.Fit}, nil
case "resize":
return DefinableProcessor{Slug: strings.ToLower(id), Converter: imaging.Resize}, nil
case "fill":
return DefinableProcessor{Slug: strings.ToLower(id), Converter: func(img image.Image, width, height int, filter imaging.ResampleFilter) *image.NRGBA {
return imaging.Fill(img, width, height, imaging.Center, filter)
}}, nil
case "thumbnail":
return DefinableProcessor{Slug: strings.ToLower(id), Converter: imaging.Thumbnail}, nil
default:
switch strings.ToLower(fileType) {
case typeGif:
return DefinableProcessor{Converter: imaging.Resize}, nil
default:
return DefinableProcessor{Converter: imaging.Thumbnail}, nil
}
}
}
@@ -0,0 +1,98 @@
package thumbnail_test
import (
"testing"
"github.com/disintegration/imaging"
tAssert "github.com/stretchr/testify/assert"
"github.com/owncloud/ocis/v2/services/thumbnails/pkg/thumbnail"
)
func TestProcessorFor(t *testing.T) {
tests := []struct {
id string
fileType string
wantP thumbnail.Processor
wantE error
}{
{
id: "fit",
fileType: "",
wantP: thumbnail.DefinableProcessor{Slug: "fit", Converter: imaging.Fit},
wantE: nil,
},
{
id: "fit",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{Slug: "fit"},
wantE: nil,
},
{
id: "FIT",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{Slug: "fit"},
wantE: nil,
},
{
id: "resize",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{Slug: "resize"},
wantE: nil,
},
{
id: "RESIZE",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{Slug: "resize"},
wantE: nil,
},
{
id: "fill",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{Slug: "fill"},
wantE: nil,
},
{
id: "FILL",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{Slug: "fill"},
wantE: nil,
},
{
id: "thumbnail",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{Slug: "thumbnail"},
wantE: nil,
},
{
id: "THUMBNAIL",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{Slug: "thumbnail"},
wantE: nil,
},
{
id: "",
fileType: "jpg",
wantP: thumbnail.DefinableProcessor{},
wantE: nil,
},
{
id: "",
fileType: "gif",
wantP: thumbnail.DefinableProcessor{},
wantE: nil,
},
}
assert := tAssert.New(t)
for _, tt := range tests {
tt := tt
t.Run("", func(t *testing.T) {
p, e := thumbnail.ProcessorFor(tt.id, tt.fileType)
assert.Equal(p.ID(), tt.wantP.ID())
assert.Equal(e, tt.wantE)
})
}
}
@@ -5,10 +5,12 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/thumbnails/pkg/config"
"github.com/pkg/errors"
)
const (
@@ -82,7 +84,14 @@ func (s FileSystem) Put(key string, img []byte) error {
func (s FileSystem) BuildKey(r Request) string {
checksum := r.Checksum
filetype := r.Types[0]
filename := strconv.Itoa(r.Resolution.Dx()) + "x" + strconv.Itoa(r.Resolution.Dy()) + "." + filetype
return filepath.Join(checksum[:2], checksum[2:4], checksum[4:], filename)
parts := []string{strconv.Itoa(r.Resolution.Dx()), "x", strconv.Itoa(r.Resolution.Dy())}
if r.Characteristic != "" {
parts = append(parts, "-", r.Characteristic)
}
parts = append(parts, ".", filetype)
return filepath.Join(checksum[:2], checksum[2:4], checksum[4:], strings.Join(parts, ""))
}
@@ -0,0 +1,65 @@
package storage_test
import (
"image"
"testing"
tAssert "github.com/stretchr/testify/assert"
"github.com/owncloud/ocis/v2/services/thumbnails/pkg/thumbnail/storage"
)
func TestFileSystem_BuildKey(t *testing.T) {
tests := []struct {
r storage.Request
want string
}{
{
r: storage.Request{
Checksum: "120EA8A25E5D487BF68B5F7096440019",
Types: []string{"png", "jpg"},
Resolution: image.Rectangle{
Min: image.Point{
X: 1,
Y: 2,
},
Max: image.Point{
X: 3,
Y: 4,
},
},
Characteristic: "",
},
want: "12/0E/A8A25E5D487BF68B5F7096440019/2x2.png",
},
{
r: storage.Request{
Checksum: "120EA8A25E5D487BF68B5F7096440019",
Types: []string{"png", "jpg"},
Resolution: image.Rectangle{
Min: image.Point{
X: 1,
Y: 2,
},
Max: image.Point{
X: 3,
Y: 4,
},
},
Characteristic: "fill",
},
want: "12/0E/A8A25E5D487BF68B5F7096440019/2x2-fill.png",
},
}
s := storage.FileSystem{}
assert := tAssert.New(t)
for _, tt := range tests {
tt := tt
t.Run("", func(t *testing.T) {
assert.Equal(s.BuildKey(tt.r), tt.want)
})
}
}
@@ -38,7 +38,13 @@ func (s InMemory) BuildKey(r Request) string {
parts := []string{
r.Checksum,
r.Resolution.String(),
strings.Join(r.Types, ","),
}
if r.Characteristic != "" {
parts = append(parts, r.Characteristic)
}
parts = append(parts, strings.Join(r.Types, ","))
return strings.Join(parts, "+")
}
@@ -0,0 +1,65 @@
package storage_test
import (
"image"
"testing"
tAssert "github.com/stretchr/testify/assert"
"github.com/owncloud/ocis/v2/services/thumbnails/pkg/thumbnail/storage"
)
func TestInMemory_BuildKey(t *testing.T) {
tests := []struct {
r storage.Request
want string
}{
{
r: storage.Request{
Checksum: "cs",
Types: []string{"png", "jpg"},
Resolution: image.Rectangle{
Min: image.Point{
X: 1,
Y: 2,
},
Max: image.Point{
X: 3,
Y: 4,
},
},
Characteristic: "",
},
want: "cs+(1,2)-(3,4)+png,jpg",
},
{
r: storage.Request{
Checksum: "cs",
Types: []string{"png", "jpg"},
Resolution: image.Rectangle{
Min: image.Point{
X: 1,
Y: 2,
},
Max: image.Point{
X: 3,
Y: 4,
},
},
Characteristic: "fill",
},
want: "cs+(1,2)-(3,4)+fill+png,jpg",
},
}
s := storage.InMemory{}
assert := tAssert.New(t)
for _, tt := range tests {
tt := tt
t.Run("", func(t *testing.T) {
assert.Equal(s.BuildKey(tt.r), tt.want)
})
}
}
@@ -15,6 +15,12 @@ type Request struct {
Types []string
// The resolution of the thumbnail
Resolution image.Rectangle
// Characteristic defines the different image characteristics,
// for example, if its scaled up to fit in the bounding box or not,
// is it a chroma version of the image, and so on...
// the main propose for this is to be able to differentiate between images which have
// the same resolution but different characteristics.
Characteristic string
}
// Storage defines the interface for a thumbnail store.
+12 -5
View File
@@ -30,6 +30,7 @@ type Request struct {
Encoder Encoder
Generator Generator
Checksum string
Processor Processor
}
// Manager is responsible for generating thumbnails
@@ -69,7 +70,7 @@ func (s SimpleManager) Generate(r Request, img interface{}) (string, error) {
match = s.resolutions.ClosestMatch(r.Resolution, m.Bounds())
}
thumbnail, err := r.Generator.GenerateThumbnail(match, img)
thumbnail, err := r.Generator.Generate(match, img, r.Processor)
if err != nil {
return "", err
}
@@ -98,9 +99,10 @@ func (s SimpleManager) GetThumbnail(key string) ([]byte, error) {
func mapToStorageRequest(r Request) storage.Request {
return storage.Request{
Checksum: r.Checksum,
Resolution: r.Resolution,
Types: r.Encoder.Types(),
Checksum: r.Checksum,
Resolution: r.Resolution,
Types: r.Encoder.Types(),
Characteristic: r.Processor.ID(),
}
}
@@ -115,7 +117,7 @@ func IsMimeTypeSupported(m string) bool {
}
// PrepareRequest prepare the request based on image parameters
func PrepareRequest(width, height int, tType, checksum string) (Request, error) {
func PrepareRequest(width, height int, tType, checksum, pID string) (Request, error) {
generator, err := GeneratorForType(tType)
if err != nil {
return Request{}, err
@@ -124,11 +126,16 @@ func PrepareRequest(width, height int, tType, checksum string) (Request, error)
if err != nil {
return Request{}, err
}
processor, err := ProcessorFor(pID, tType)
if err != nil {
return Request{}, err
}
return Request{
Resolution: image.Rect(0, 0, width, height),
Generator: generator,
Encoder: encoder,
Checksum: checksum,
Processor: processor,
}, nil
}
@@ -4,9 +4,11 @@ import (
"image"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/thumbnails/pkg/thumbnail/storage"
)
@@ -118,13 +120,15 @@ func TestPrepareRequest(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := PrepareRequest(tt.args.width, tt.args.height, tt.args.tType, tt.args.checksum)
got, err := PrepareRequest(tt.args.width, tt.args.height, tt.args.tType, tt.args.checksum, "")
if (err != nil) != tt.wantErr {
t.Errorf("PrepareRequest() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("PrepareRequest() got = %v, want %v", got, tt.want)
// func's are not reflactable, ignore
if diff := cmp.Diff(tt.want, got, cmpopts.IgnoreFields(Request{}, "Processor")); diff != "" {
t.Errorf("PrepareRequest(): %v", diff)
}
})
}