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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user