diff --git a/imagor.go b/imagor.go index 9f7df6c9a..efc6df57a 100644 --- a/imagor.go +++ b/imagor.go @@ -95,8 +95,7 @@ func New(options ...Option) *Imagor { if app.Signer == nil { app.Signer = imagorpath.NewDefaultSigner("") } - // cast storages into loaders - app.ResultLoaders = loaderSlice(app.ResultStorages) + app.ResultLoaders = append(loaderSlice(app.ResultStorages), app.ResultLoaders...) app.Loaders = append(loaderSlice(app.Storages), app.Loaders...) return app } diff --git a/processor/vipsprocessor/image.go b/processor/vipsprocessor/image.go index e8c56c371..8762f7ef3 100644 --- a/processor/vipsprocessor/image.go +++ b/processor/vipsprocessor/image.go @@ -3,6 +3,7 @@ package vipsprocessor import ( "github.com/cshum/imagor" "github.com/davidbyttow/govips/v2/vips" + "math" ) func (v *VipsProcessor) newThumbnail( @@ -115,13 +116,30 @@ func (v *VipsProcessor) thumbnail( return v.animatedThumbnailWithCrop(img, width, height, crop, size) } +func (v *VipsProcessor) focalThumbnail(img *vips.ImageRef, w, h int, fx, fy float64) (err error) { + if float64(w)/float64(h) > float64(img.Width())/float64(img.PageHeight()) { + if err = img.Thumbnail(w, v.MaxHeight, vips.InterestingNone); err != nil { + return + } + } else { + if err = img.Thumbnail(v.MaxWidth, h, vips.InterestingNone); err != nil { + return + } + } + var top, left float64 + left = float64(img.Width())*fx - float64(w)/2 + top = float64(img.PageHeight())*fy - float64(h)/2 + left = math.Max(0, math.Min(left, float64(img.Width()-w))) + top = math.Max(0, math.Min(top, float64(img.PageHeight()-h))) + return img.ExtractArea(int(left), int(top), w, h) +} + func (v *VipsProcessor) animatedThumbnailWithCrop( img *vips.ImageRef, w, h int, crop vips.Interesting, size vips.Size, ) (err error) { if size == vips.SizeDown && img.Width() < w && img.PageHeight() < h { return } - // use ExtractArea for animated cropping var top, left int if float64(w)/float64(h) > float64(img.Width())/float64(img.PageHeight()) { if err = img.ThumbnailWithSize(w, v.MaxHeight, vips.InterestingNone, size); err != nil { diff --git a/processor/vipsprocessor/process.go b/processor/vipsprocessor/process.go index f6be4d38f..d2bb2f1b6 100644 --- a/processor/vipsprocessor/process.go +++ b/processor/vipsprocessor/process.go @@ -14,7 +14,7 @@ import ( ) func (v *VipsProcessor) process( - ctx context.Context, img *vips.ImageRef, p imagorpath.Params, load imagor.LoadFunc, thumbnail, stretch, upscale bool, + ctx context.Context, img *vips.ImageRef, p imagorpath.Params, load imagor.LoadFunc, thumbnail, stretch, upscale bool, focalRects []focal, ) error { if p.Trim { if err := trim(ctx, img, p.TrimBy, p.TrimTolerance); err != nil { @@ -76,8 +76,19 @@ func (v *VipsProcessor) process( interest = vips.InterestingHigh } } - if err := v.thumbnail(img, w, h, interest, vips.SizeBoth); err != nil { - return err + if p.Smart && len(focalRects) > 0 { + focalX, focalY := parseFocalPoint(focalRects...) + if err := v.focalThumbnail( + img, w, h, + focalX/float64(img.Width()), + focalY/float64(img.PageHeight()), + ); err != nil { + return err + } + } else { + if err := v.thumbnail(img, w, h, interest, vips.SizeBoth); err != nil { + return err + } } } } @@ -124,6 +135,26 @@ func (v *VipsProcessor) process( return nil } +type focal struct { + Left float64 + Right float64 + Top float64 + Bottom float64 +} + +func parseFocalPoint(focalRects ...focal) (focalX, focalY float64) { + var sumWeight float64 + for _, f := range focalRects { + sumWeight += (f.Right - f.Left) * (f.Bottom - f.Top) + } + for _, f := range focalRects { + r := (f.Right - f.Left) * (f.Bottom - f.Top) / sumWeight + focalX += (f.Left + f.Right) / 2 * r + focalY += (f.Top + f.Bottom) / 2 * r + } + return +} + func trim(ctx context.Context, img *vips.ImageRef, pos string, tolerance int) error { if IsAnimated(ctx) { // skip animation support diff --git a/processor/vipsprocessor/vips.go b/processor/vipsprocessor/vips.go index 650d6a577..8813a14c2 100644 --- a/processor/vipsprocessor/vips.go +++ b/processor/vipsprocessor/vips.go @@ -117,6 +117,10 @@ func (v *VipsProcessor) Shutdown(_ context.Context) error { return nil } +func focalSplit(r rune) bool { + return r == 'x' || r == ',' || r == ':' +} + func (v *VipsProcessor) Process( ctx context.Context, blob *imagor.Bytes, p imagorpath.Params, load imagor.LoadFunc, ) (*imagor.Bytes, error) { @@ -129,6 +133,7 @@ func (v *VipsProcessor) Process( format = vips.ImageTypeUnknown maxN = v.MaxAnimationFrames maxBytes int + focalRects []focal err error ) ctx = WithInitImageRefs(ctx) @@ -173,6 +178,9 @@ func (v *VipsProcessor) Process( thumbnailNotSupported = true } break + case "focal": + thumbnailNotSupported = true + break case "trim": thumbnailNotSupported = true break @@ -276,6 +284,8 @@ func (v *VipsProcessor) Process( var ( quality int pageN = img.Height() / img.PageHeight() + dw = float64(img.Width()) + dh = float64(img.PageHeight()) ) if format == vips.ImageTypeUnknown { format = img.Format() @@ -296,9 +306,27 @@ func (v *VipsProcessor) Process( case "autojpg": format = vips.ImageTypeJPEG break + case "focal": + if args := strings.FieldsFunc(p.Args, focalSplit); len(args) == 4 { + f := focal{} + f.Left, _ = strconv.ParseFloat(args[0], 64) + f.Top, _ = strconv.ParseFloat(args[1], 64) + f.Right, _ = strconv.ParseFloat(args[2], 64) + f.Bottom, _ = strconv.ParseFloat(args[3], 64) + if f.Left < 1 && f.Top < 1 && f.Right <= 1 && f.Bottom <= 1 { + f.Left *= dw + f.Right *= dw + f.Top *= dh + f.Bottom *= dh + } + if f.Right > f.Left && f.Bottom > f.Top { + focalRects = append(focalRects, f) + } + } + break } } - if err := v.process(ctx, img, p, load, thumbnail, stretch, upscale); err != nil { + if err := v.process(ctx, img, p, load, thumbnail, stretch, upscale, focalRects); err != nil { return nil, wrapErr(err) } for { diff --git a/processor/vipsprocessor/vips_test.go b/processor/vipsprocessor/vips_test.go index e5c6c8ef8..c2ee2cceb 100644 --- a/processor/vipsprocessor/vips_test.go +++ b/processor/vipsprocessor/vips_test.go @@ -65,16 +65,16 @@ type test struct { } func doTests(t *testing.T, resultDir string, tests []test, opts ...Option) { + resStorage := filestorage.New( + resultDir, + filestorage.WithSaveErrIfExists(true), + ) app := imagor.New( imagor.WithLoaders(filestorage.New(testDataDir)), imagor.WithUnsafe(true), imagor.WithDebug(true), imagor.WithLogger(zap.NewExample()), imagor.WithProcessors(New(opts...)), - imagor.WithResultStorages(filestorage.New( - resultDir, - filestorage.WithSaveErrIfExists(true), - )), ) require.NoError(t, app.Startup(context.Background())) t.Parallel() @@ -84,6 +84,7 @@ func doTests(t *testing.T, resultDir string, tests []test, opts ...Option) { app.ServeHTTP(w, httptest.NewRequest( http.MethodGet, fmt.Sprintf("/unsafe/%s", tt.path), nil)) assert.Equal(t, 200, w.Code) + _ = resStorage.Put(context.Background(), tt.path, imagor.NewBytes(w.Body.Bytes())) path := filepath.Join(resultDir, imagorpath.Normalize(tt.path, nil)) buf, err := ioutil.ReadFile(path) @@ -112,6 +113,8 @@ func TestVipsProcessor(t *testing.T) { {"original", "gopher-front.png"}, {"resize center", "100x100/filters:quality(70):format(jpeg)/gopher.png"}, {"resize smart", "100x100/smart/filters:autojpg()/gopher.png"}, + {"resize smart focal", "300x100/smart/filters:fill(white):format(jpeg):focal(589x401:1000x814)/gopher.png"}, + {"resize smart focal float", "300x100/smart/filters:fill(white):format(jpeg):focal(0.35x0.25:0.6x0.3)/gopher.png"}, {"resize top", "200x100/top/filters:quality(70):format(tiff)/gopher.png"}, {"resize top", "200x100/right/top/gopher.png"}, {"resize bottom", "200x100/bottom/gopher.png"}, @@ -151,6 +154,7 @@ func TestVipsProcessor(t *testing.T) { {"original animated", "dancing-banana.gif"}, {"crop animated", "30x20:100x150/dancing-banana.gif"}, {"crop-percent animated", "0.1x0.2:0.89x0.72/dancing-banana.gif"}, + {"smart focal animated", "100x30/smart/filters:focal(0.1x0:0.89x0.72)/dancing-banana.gif"}, //{"resize center animated", "100x100/dancing-banana.gif"}, //{"resize top animated", "200x100/top/dancing-banana.gif"}, //{"resize top animated", "200x100/right/top/dancing-banana.gif"}, diff --git a/testdata/result/100x30/smart/filters%3Afocal%280.1x0%3A0.89x0.72%29/dancing-banana.gif b/testdata/result/100x30/smart/filters%3Afocal%280.1x0%3A0.89x0.72%29/dancing-banana.gif new file mode 100644 index 000000000..56c1d146f Binary files /dev/null and b/testdata/result/100x30/smart/filters%3Afocal%280.1x0%3A0.89x0.72%29/dancing-banana.gif differ diff --git a/testdata/result/300x100/smart/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%280.35x0.25%3A0.6x0.3%29/gopher.png b/testdata/result/300x100/smart/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%280.35x0.25%3A0.6x0.3%29/gopher.png new file mode 100644 index 000000000..61f105b68 Binary files /dev/null and b/testdata/result/300x100/smart/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%280.35x0.25%3A0.6x0.3%29/gopher.png differ diff --git a/testdata/result/300x100/smart/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%28589x401%3A1000x814%29/gopher.png b/testdata/result/300x100/smart/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%28589x401%3A1000x814%29/gopher.png new file mode 100644 index 000000000..80707b88e Binary files /dev/null and b/testdata/result/300x100/smart/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%28589x401%3A1000x814%29/gopher.png differ