Skip to content
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ require (
github.com/dgraph-io/ristretto v0.2.0 // indirect
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/disintegration/gift v1.1.2 // indirect
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec // indirect
github.com/djherbis/atime v1.0.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dsoprea/go-exif/v2 v2.0.0-20200604193436-ca8584a0e1c4 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,10 @@ github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUn
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
Expand Down
12 changes: 11 additions & 1 deletion scheduler/actions/images/encoding/codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"image/color"
"io"

"github.com/disintegration/imageorient"
"github.com/disintegration/imaging"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can replace this library as a next step

_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
Expand All @@ -39,6 +40,7 @@ const (
BMP
WEBP
TIFF
GIF
)

// ResizeFilter represents the resizing algorithm
Expand Down Expand Up @@ -82,7 +84,13 @@ func NewImageCodec(fileExt string) ImageCodec {

// Decode reads an image from the provided reader
func (c defaultCodec) Decode(reader io.Reader) (image.Image, error) {
return imaging.Decode(reader)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry to bother again :-)
Does this have any perf implication for formats that do not support exif ? It seems that exif is JPG/TIFF only, maybe we should use the "standard" decode for BMP, PNG, WEBP...?

Copy link
Contributor Author

@cristianoliveira cristianoliveira Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There might be a minor performance hit. For our case I believe is better to configure it upon decoder instantiation, to avoid having non-intuitive rotation only for one format type. Something like:

	t.codec = encoding.NewImageCodec(fileFormat, &encoding.CodecOptions{
		EnforceExifOrientation: true,
	})

Then in case is not enforced it uses the standard encoding

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I notice also that there are some img types that simply don't support exif at all, for instance, GIF / BMP. I see the underlining library handle these already

// Formats that don't support EXIF, so we avoid the extra processing
if c.format == GIF || c.format == BMP {
return imaging.Decode(reader)
}

img, _, err := imageorient.Decode(reader)
return img, err
}

// Encode writes an image to the provided writer in the specified format
Expand Down Expand Up @@ -139,6 +147,8 @@ func extensionToFormat(ext string) ImageFormat {
return WEBP
case ".tiff", ".tif":
return TIFF
case ".gif":
return GIF
default:
return JPEG
}
Expand Down
7 changes: 0 additions & 7 deletions scheduler/actions/images/exif.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,6 @@ func (e *ExifProcessor) Run(ctx context.Context, channels *actions.RunnableChann

output := input.Clone()
node.MustSetMeta(MetadataExif, exifData)
orientation, oe := exifData.Get(exif.Orientation)
if oe == nil {
t := orientation.String()
if t != "" {
node.MustSetMeta(MetadataCompatOrientation, t)
}
}
lat, long, err := exifData.LatLong()
if err == nil {
var readLat, readLong string
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion scheduler/actions/images/thumbnails.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,6 @@ func (t *ThumbnailExtractor) Run(ctx context.Context, channels *actions.Runnable
}

fileFormat := strings.ToLower(filepath.Ext(input.Nodes[0].GetStringMeta(common.MetaNamespaceNodeName)))

t.codec = encoding.NewImageCodec(fileFormat)

log.Logger(ctx).Debug("[THUMB EXTRACTOR] Resizing image...")
Expand Down
101 changes: 101 additions & 0 deletions scheduler/actions/images/thumbnails_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ package images
import (
"context"
"fmt"
"image"
_ "image/jpeg"
"os"
"path/filepath"
"testing"
Expand All @@ -33,6 +35,7 @@ import (
"github.com/pydio/cells/v5/common/proto/tree"
"github.com/pydio/cells/v5/common/utils/uuid"
"github.com/pydio/cells/v5/scheduler/actions"
"github.com/rwcarlsen/goexif/exif"

. "github.com/smartystreets/goconvey/convey"
)
Expand Down Expand Up @@ -135,6 +138,104 @@ func TestThumbnailExtractor_RunFormats(t *testing.T) {
})
}

func TestThumbnailExtractor_RunOrientation(t *testing.T) {
Convey("Thumbnails respect EXIF orientation", t, func() {
cases := []struct {
name string
sourceFile string
}{
// How to check the exif of these images:
// exiftool -Orientation orientation/landscape_3.jpg
// Orientation : Rotate 180
{
name: "Landscape - Orientation : Rotate 180",
sourceFile: filepath.Join("orientation", "landscape_3.jpg"),
},
// exiftool -Orientation orientation/portrait_4.jpg
// Orientation : Mirror vertical
{
name: "Portrait - Orientation : Mirror vertical",
sourceFile: filepath.Join("orientation", "portrait_4.jpg"),
},
}

for _, testcase := range cases {
tc := testcase
Convey(tc.name, func() {
// Given
orientation := readExifOrientation(t, tc.sourceFile)
So(orientation, ShouldBeGreaterThan, 0)
rawWidth, rawHeight := readImageDimensions(t, tc.sourceFile)
expectedLandscape := landscapeAfterOrientation(orientation, rawWidth, rawHeight)

result, err := runThumbnailForFormat(t, `{"sm":256}`, tc.sourceFile, ".jpg")
So(err, ShouldBeNil)

thumbPath := assertThumbnailExists(t, result.tmpDir, result.uuid, 256)

file, err := os.Open(thumbPath)
So(err, ShouldBeNil)
defer file.Close()

// When
img, _, err := image.Decode(file)
So(err, ShouldBeNil)

// Then
width := img.Bounds().Dx()
height := img.Bounds().Dy()

if expectedLandscape {
So(width, ShouldBeGreaterThanOrEqualTo, height)
} else {
So(height, ShouldBeGreaterThanOrEqualTo, width)
}
})
}
})
}

func readExifOrientation(t *testing.T, sourceFile string) int {
t.Helper()

file, err := os.Open(filepath.Join("testdata", sourceFile))
So(err, ShouldBeNil)
defer file.Close()

info, err := exif.Decode(file)
So(err, ShouldBeNil)

tag, err := info.Get(exif.Orientation)
So(err, ShouldBeNil)

val, err := tag.Int(0)
So(err, ShouldBeNil)

return val
}

func readImageDimensions(t *testing.T, sourceFile string) (int, int) {
t.Helper()

file, err := os.Open(filepath.Join("testdata", sourceFile))
So(err, ShouldBeNil)
defer file.Close()

img, _, err := image.Decode(file)
So(err, ShouldBeNil)

bounds := img.Bounds()
return bounds.Dx(), bounds.Dy()
}

func landscapeAfterOrientation(orientation, width, height int) bool {
switch orientation {
case 5, 6, 7, 8:
width, height = height, width // Swap dimensions for rotations
}
return width >= height
}

type thumbnailRunResult struct {
tmpDir string
uuid string
Expand Down