diff --git a/go.mod b/go.mod index ddda8eec1..7f7d61703 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/remeh/sizedwaitgroup v1.0.0 + github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f github.com/sirupsen/logrus v1.9.3 github.com/spf13/cast v1.6.0 diff --git a/go.sum b/go.sum index 6d6762c54..8c3b00d61 100644 --- a/go.sum +++ b/go.sum @@ -590,6 +590,8 @@ github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiS github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= diff --git a/pkg/file/image/orientation.go b/pkg/file/image/orientation.go new file mode 100644 index 000000000..84f5774cf --- /dev/null +++ b/pkg/file/image/orientation.go @@ -0,0 +1,75 @@ +package image + +import ( + "errors" + "fmt" + "io" + + "github.com/rwcarlsen/goexif/exif" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" +) + +func adjustForOrientation(fs models.FS, path string, f *models.ImageFile) { + isFlipped, err := areDimensionsFlipped(fs, path) + if err != nil { + logger.Warnf("Error determining image orientation for %s: %v", path, err) + // isFlipped is false by default + } + + if isFlipped { + f.Width, f.Height = f.Height, f.Width + } +} + +// areDimensionsFlipped returns true if the image dimensions are flipped. +// This is determined by the EXIF orientation tag. +func areDimensionsFlipped(fs models.FS, path string) (bool, error) { + r, err := fs.Open(path) + if err != nil { + return false, fmt.Errorf("reading image file %q: %w", path, err) + } + defer r.Close() + + x, err := exif.Decode(r) + if err != nil { + if errors.Is(err, io.EOF) { + // no exif data + return false, nil + } + + return false, fmt.Errorf("decoding exif data: %w", err) + } + + o, err := x.Get(exif.Orientation) + if err != nil { + // assume not present + return false, nil + } + + oo, err := o.Int(0) + if err != nil { + return false, fmt.Errorf("decoding orientation: %w", err) + } + + return isOrientationDimensionsFlipped(oo), nil +} + +// isOrientationDimensionsFlipped returns true if the image orientation is flipped based on the input orientation EXIF value. +// From https://sirv.com/help/articles/rotate-photos-to-be-upright/ +// 1 = 0 degrees: the correct orientation, no adjustment is required. +// 2 = 0 degrees, mirrored: image has been flipped back-to-front. +// 3 = 180 degrees: image is upside down. +// 4 = 180 degrees, mirrored: image has been flipped back-to-front and is upside down. +// 5 = 90 degrees: image has been flipped back-to-front and is on its side. +// 6 = 90 degrees, mirrored: image is on its side. +// 7 = 270 degrees: image has been flipped back-to-front and is on its far side. +// 8 = 270 degrees, mirrored: image is on its far side. +func isOrientationDimensionsFlipped(o int) bool { + switch o { + case 5, 6, 7, 8: + return true + default: + return false + } +} diff --git a/pkg/file/image/scan.go b/pkg/file/image/scan.go index b78de91e0..258f3e42b 100644 --- a/pkg/file/image/scan.go +++ b/pkg/file/image/scan.go @@ -25,36 +25,17 @@ type Decorator struct { func (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) (models.File, error) { base := f.Base() - decorateFallback := func() (models.File, error) { - r, err := fs.Open(base.Path) - if err != nil { - return f, fmt.Errorf("reading image file %q: %w", base.Path, err) - } - defer r.Close() - - c, format, err := image.DecodeConfig(r) - if err != nil { - return f, fmt.Errorf("decoding image file %q: %w", base.Path, err) - } - return &models.ImageFile{ - BaseFile: base, - Format: format, - Width: c.Width, - Height: c.Height, - }, nil - } - // ignore clips in non-OsFS filesystems as ffprobe cannot read them // TODO - copy to temp file if not an OsFS if _, isOs := fs.(*file.OsFS); !isOs { logger.Debugf("assuming ImageFile for non-OsFS file %q", base.Path) - return decorateFallback() + return decorateFallback(fs, f) } probe, err := d.FFProbe.NewVideoFile(base.Path) if err != nil { logger.Warnf("File %q could not be read with ffprobe: %s, assuming ImageFile", base.Path, err) - return decorateFallback() + return decorateFallback(fs, f) } // Fallback to catch non-animated avif images that FFProbe detects as video files @@ -79,12 +60,53 @@ func (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) ( return videoFileDecorator.Decorate(ctx, fs, f) } - return &models.ImageFile{ + ret := &models.ImageFile{ BaseFile: base, Format: probe.VideoCodec, Width: probe.Width, Height: probe.Height, - }, nil + } + + adjustForOrientation(fs, base.Path, ret) + + return ret, nil +} + +func decodeConfig(fs models.FS, path string) (config image.Config, format string, err error) { + r, err := fs.Open(path) + if err != nil { + err = fmt.Errorf("reading image file %q: %w", path, err) + return + } + defer r.Close() + + config, format, err = image.DecodeConfig(r) + if err != nil { + err = fmt.Errorf("decoding image file %q: %w", path, err) + return + } + return +} + +func decorateFallback(fs models.FS, f models.File) (models.File, error) { + base := f.Base() + path := base.Path + + c, format, err := decodeConfig(fs, path) + if err != nil { + return f, err + } + + ret := &models.ImageFile{ + BaseFile: base, + Format: format, + Width: c.Width, + Height: c.Height, + } + + adjustForOrientation(fs, path, ret) + + return ret, nil } func (d *Decorator) IsMissingMetadata(ctx context.Context, fs models.FS, f models.File) bool {