HEIC: Reset Exif orientation for compatibility with libheif 1.18.1 #4439

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2024-08-03 16:31:11 +02:00
parent a19bf9bd61
commit a91552d351
25 changed files with 691 additions and 437 deletions

2
go.mod
View File

@@ -38,7 +38,7 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.9.0
github.com/tensorflow/tensorflow v1.15.2
github.com/tidwall/gjson v1.17.1
github.com/tidwall/gjson v1.17.3
github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6
github.com/urfave/cli v1.22.15
go4.org v0.0.0-20230225012048-214862532bf5 // indirect

4
go.sum
View File

@@ -392,8 +392,8 @@ github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNv
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
github.com/tensorflow/tensorflow v1.15.2 h1:7/f/A664Tml/nRJg04+p3StcrsT53mkcvmxYHXI21Qo=
github.com/tensorflow/tensorflow v1.15.2/go.mod h1:itOSERT4trABok4UOoG+X4BoKds9F3rIsySdn+Lvu90=
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94=
github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=

View File

@@ -1,54 +0,0 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestConfig_VectorEnabled(t *testing.T) {
c := NewConfig(CliTestContext())
assert.True(t, c.VectorEnabled())
c.options.DisableVectors = true
assert.False(t, c.VectorEnabled())
c.options.DisableVectors = false
}
func TestConfig_RsvgConvertBin2(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Contains(t, c.RsvgConvertBin(), "rsvg-convert")
}
func TestConfig_ImageMagickBin(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Contains(t, c.ImageMagickBin(), "convert")
}
func TestConfig_ImageMagickEnabled(t *testing.T) {
c := NewConfig(CliTestContext())
assert.True(t, c.ImageMagickEnabled())
c.options.DisableImageMagick = true
assert.False(t, c.ImageMagickEnabled())
c.options.DisableImageMagick = false
}
func TestConfig_JpegXLDecoderBin(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Contains(t, c.JpegXLDecoderBin(), "djxl")
}
func TestConfig_JpegXLEnabled(t *testing.T) {
c := NewConfig(CliTestContext())
assert.True(t, c.JpegXLEnabled())
c.options.DisableJpegXL = true
assert.False(t, c.JpegXLEnabled())
c.options.DisableJpegXL = false
}
func TestConfig_DisableJpegXL(t *testing.T) {
c := NewConfig(CliTestContext())
assert.False(t, c.DisableJpegXL())
c.options.DisableJpegXL = true
assert.True(t, c.DisableJpegXL())
c.options.DisableJpegXL = false
}

View File

@@ -1,5 +1,9 @@
package config
import (
"github.com/photoprism/photoprism/pkg/media"
)
// VectorEnabled checks if indexing and conversion of vector graphics is enabled.
func (c *Config) VectorEnabled() bool {
return !c.DisableVectors()
@@ -50,3 +54,34 @@ func (c *Config) DisableJpegXL() bool {
return c.options.DisableJpegXL
}
// HeifConvertBin returns the name of the "heif-dec" executable ("heif-convert" in earlier libheif versions).
// see https://github.com/photoprism/photoprism/issues/4439
func (c *Config) HeifConvertBin() string {
return findBin(c.options.HeifConvertBin, "heif-dec", "heif-convert")
}
// HeifConvertOrientation returns the Exif orientation of images generated with libheif (auto, strip, keep).
func (c *Config) HeifConvertOrientation() media.Orientation {
return media.ParseOrientation(c.options.HeifConvertOrientation, media.ResetOrientation)
}
// HeifConvertEnabled checks if heif-convert is enabled for HEIF conversion.
func (c *Config) HeifConvertEnabled() bool {
return !c.DisableHeifConvert()
}
// SipsEnabled checks if SIPS is enabled for RAW conversion.
func (c *Config) SipsEnabled() bool {
return !c.DisableSips()
}
// SipsBin returns the SIPS executable file name.
func (c *Config) SipsBin() string {
return findBin(c.options.SipsBin, "sips")
}
// SipsExclude returns the file extensions no not be used with Sips.
func (c *Config) SipsExclude() string {
return c.options.SipsExclude
}

View File

@@ -83,28 +83,3 @@ func (c *Config) RawTherapeeExclude() string {
func (c *Config) RawTherapeeEnabled() bool {
return !c.DisableRawTherapee()
}
// SipsEnabled checks if SIPS is enabled for RAW conversion.
func (c *Config) SipsEnabled() bool {
return !c.DisableSips()
}
// SipsBin returns the SIPS executable file name.
func (c *Config) SipsBin() string {
return findBin(c.options.SipsBin, "sips")
}
// SipsExclude returns the file extensions no not be used with Sips.
func (c *Config) SipsExclude() string {
return c.options.SipsExclude
}
// HeifConvertBin returns the heif-convert executable file name.
func (c *Config) HeifConvertBin() string {
return findBin(c.options.HeifConvertBin, "heif-convert")
}
// HeifConvertEnabled checks if heif-convert is enabled for HEIF conversion.
func (c *Config) HeifConvertEnabled() bool {
return !c.DisableHeifConvert()
}

View File

@@ -62,54 +62,6 @@ func TestConfig_DarktableEnabled(t *testing.T) {
assert.False(t, c.DarktableEnabled())
}
func TestConfig_SipsBin(t *testing.T) {
c := NewConfig(CliTestContext())
bin := c.SipsBin()
assert.Equal(t, "", bin)
}
func TestConfig_SipsEnabled(t *testing.T) {
c := NewConfig(CliTestContext())
assert.NotEqual(t, c.DisableSips(), c.SipsEnabled())
}
func TestConfig_SipsExclude(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "avif, avifs, thm", c.SipsExclude())
}
func TestConfig_HeifConvertBin(t *testing.T) {
c := NewConfig(CliTestContext())
bin := c.HeifConvertBin()
assert.Contains(t, bin, "/bin/heif-convert")
}
func TestConfig_HeifConvertEnabled(t *testing.T) {
c := NewConfig(CliTestContext())
assert.True(t, c.HeifConvertEnabled())
c.options.DisableHeifConvert = true
assert.False(t, c.HeifConvertEnabled())
}
func TestConfig_RsvgConvertBin(t *testing.T) {
c := NewConfig(CliTestContext())
bin := c.RsvgConvertBin()
assert.Contains(t, bin, "/bin/rsvg-convert")
}
func TestConfig_RsvgConvertEnabled(t *testing.T) {
c := NewConfig(CliTestContext())
assert.True(t, c.RsvgConvertEnabled())
c.options.DisableVectors = true
assert.False(t, c.RsvgConvertEnabled())
}
func TestConfig_CreateDarktableCachePath(t *testing.T) {
c := NewConfig(CliTestContext())
path, err := c.CreateDarktableCachePath()

View File

@@ -0,0 +1,113 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/media"
)
func TestConfig_HeifConvertBin(t *testing.T) {
c := NewConfig(CliTestContext())
bin := c.HeifConvertBin()
assert.Contains(t, bin, "/bin/heif-")
}
func TestConfig_HeifConvertOrientation(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, media.ResetOrientation, c.HeifConvertOrientation())
c.Options().HeifConvertOrientation = media.KeepOrientation
assert.Equal(t, media.KeepOrientation, c.HeifConvertOrientation())
c.Options().HeifConvertOrientation = ""
assert.Equal(t, media.ResetOrientation, c.HeifConvertOrientation())
}
func TestConfig_HeifConvertEnabled(t *testing.T) {
c := NewConfig(CliTestContext())
assert.True(t, c.HeifConvertEnabled())
c.options.DisableHeifConvert = true
assert.False(t, c.HeifConvertEnabled())
}
func TestConfig_SipsBin(t *testing.T) {
c := NewConfig(CliTestContext())
bin := c.SipsBin()
assert.Equal(t, "", bin)
}
func TestConfig_SipsEnabled(t *testing.T) {
c := NewConfig(CliTestContext())
assert.NotEqual(t, c.DisableSips(), c.SipsEnabled())
}
func TestConfig_SipsExclude(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "avif, avifs, thm", c.SipsExclude())
}
func TestConfig_RsvgConvertBin(t *testing.T) {
c := NewConfig(CliTestContext())
bin := c.RsvgConvertBin()
assert.Contains(t, bin, "/bin/rsvg-convert")
}
func TestConfig_RsvgConvertEnabled(t *testing.T) {
c := NewConfig(CliTestContext())
assert.True(t, c.RsvgConvertEnabled())
c.options.DisableVectors = true
assert.False(t, c.RsvgConvertEnabled())
}
func TestConfig_VectorEnabled(t *testing.T) {
c := NewConfig(CliTestContext())
assert.True(t, c.VectorEnabled())
c.options.DisableVectors = true
assert.False(t, c.VectorEnabled())
c.options.DisableVectors = false
}
func TestConfig_RsvgConvertBin2(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Contains(t, c.RsvgConvertBin(), "rsvg-convert")
}
func TestConfig_ImageMagickBin(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Contains(t, c.ImageMagickBin(), "convert")
}
func TestConfig_ImageMagickEnabled(t *testing.T) {
c := NewConfig(CliTestContext())
assert.True(t, c.ImageMagickEnabled())
c.options.DisableImageMagick = true
assert.False(t, c.ImageMagickEnabled())
c.options.DisableImageMagick = false
}
func TestConfig_JpegXLDecoderBin(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Contains(t, c.JpegXLDecoderBin(), "djxl")
}
func TestConfig_JpegXLEnabled(t *testing.T) {
c := NewConfig(CliTestContext())
assert.True(t, c.JpegXLEnabled())
c.options.DisableJpegXL = true
assert.False(t, c.JpegXLEnabled())
c.options.DisableJpegXL = false
}
func TestConfig_DisableJpegXL(t *testing.T) {
c := NewConfig(CliTestContext())
assert.False(t, c.DisableJpegXL())
c.options.DisableJpegXL = true
assert.True(t, c.DisableJpegXL())
c.options.DisableJpegXL = false
}

View File

@@ -8,6 +8,7 @@ import (
"os/user"
"path/filepath"
"runtime"
"strings"
"sync"
"github.com/photoprism/photoprism/pkg/clean"
@@ -23,38 +24,50 @@ var (
)
// findBin resolves the absolute file path of external binaries.
func findBin(configBin, defaultBin string) (binPath string) {
cacheKey := defaultBin + configBin
func findBin(configBin string, defaultBin ...string) (binPath string) {
// Binary file paths to be checked.
var search []string
if configBin != "" {
search = []string{configBin}
} else {
search = defaultBin
}
// Cache key for the binary file path.
binKey := strings.Join(append(defaultBin, configBin), ",")
// Check if file path is cached.
binMu.RLock()
cached, found := binPaths[cacheKey]
cached, found := binPaths[binKey]
binMu.RUnlock()
// Already found?
// Found in cache?
if found {
return cached
}
// Default binary name?
if configBin == "" {
binPath = defaultBin
} else {
binPath = configBin
}
// Search for binary.
if path, err := exec.LookPath(binPath); err == nil {
// Check binary file paths.
for _, binPath = range search {
if binPath == "" {
continue
} else if path, err := exec.LookPath(binPath); err == nil {
binPath = path
break
}
}
// Found?
if !fs.FileExists(binPath) {
binPath = ""
} else {
// Cache result if exists.
binMu.Lock()
binPaths[cacheKey] = binPath
binPaths[binKey] = binPath
binMu.Unlock()
}
// Return result.
return binPath
}

View File

@@ -9,8 +9,15 @@ import (
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestConfig_FindExecutable(t *testing.T) {
assert.Equal(t, "", findBin("yyy", "xxx"))
func TestConfig_findBin(t *testing.T) {
assert.Equal(t, "", findBin("yyy123", "xxx123"))
assert.Equal(t, "", findBin("yyy123", "sh"))
assert.Equal(t, "/usr/bin/sh", findBin("sh", "yyy123"))
assert.Equal(t, "/usr/bin/sh", findBin("", "sh"))
assert.Equal(t, "/usr/bin/sh", findBin("", "", "sh"))
assert.Equal(t, "/usr/bin/sh", findBin("", "yyy123", "sh"))
assert.Equal(t, "/usr/bin/sh", findBin("sh", "bash"))
assert.Equal(t, "/usr/bin/bash", findBin("bash", "sh"))
}
func TestConfig_SidecarPath(t *testing.T) {

View File

@@ -813,8 +813,15 @@ var Flags = CliFlags{
Flag: cli.StringFlag{
Name: "heifconvert-bin",
Usage: "libheif HEIC image conversion `COMMAND`",
Value: "heif-convert",
Value: "",
EnvVar: EnvVar("HEIFCONVERT_BIN"),
},
DocDefault: "heif-dec"}, {
Flag: cli.StringFlag{
Name: "heifconvert-orientation",
Usage: "Exif `ORIENTATION` of images generated with libheif (reset, keep)",
Value: "reset",
EnvVar: EnvVar("HEIFCONVERT_ORIENTATION"),
}}, {
Flag: cli.StringFlag{
Name: "download-token",

View File

@@ -171,6 +171,7 @@ type Options struct {
ImageMagickBin string `yaml:"ImageMagickBin" json:"-" flag:"imagemagick-bin"`
ImageMagickExclude string `yaml:"ImageMagickExclude" json:"-" flag:"imagemagick-exclude"`
HeifConvertBin string `yaml:"HeifConvertBin" json:"-" flag:"heifconvert-bin"`
HeifConvertOrientation string `yaml:"HeifConvertOrientation" json:"-" flag:"heifconvert-orientation"`
RsvgConvertBin string `yaml:"RsvgConvertBin" json:"-" flag:"rsvgconvert-bin"`
DownloadToken string `yaml:"DownloadToken" json:"-" flag:"download-token"`
PreviewToken string `yaml:"PreviewToken" json:"-" flag:"preview-token"`

View File

@@ -219,6 +219,7 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"imagemagick-bin", c.ImageMagickBin()},
{"imagemagick-exclude", c.ImageMagickExclude()},
{"heifconvert-bin", c.HeifConvertBin()},
{"heifconvert-orientation", c.HeifConvertOrientation()},
{"rsvgconvert-bin", c.RsvgConvertBin()},
{"jpegxldecoder-bin", c.JpegXLDecoderBin()},

View File

@@ -16,7 +16,7 @@ import (
"github.com/photoprism/photoprism/pkg/list"
)
// Convert represents a converter that can convert RAW/HEIF images to JPEG.
// Convert represents a file format conversion worker.
type Convert struct {
conf *config.Config
cmdMutex sync.Mutex
@@ -26,7 +26,7 @@ type Convert struct {
imageMagickExclude fs.ExtList
}
// NewConvert returns a new converter and expects the config as argument.
// NewConvert returns a new file format conversion worker.
func NewConvert(conf *config.Config) *Convert {
c := &Convert{
conf: conf,
@@ -39,8 +39,8 @@ func NewConvert(conf *config.Config) *Convert {
return c
}
// Start converts all files in a directory to JPEG if possible.
func (c *Convert) Start(dir string, ext []string, force bool) (err error) {
// Start converts all files in the specified directory based on the current configuration.
func (w *Convert) Start(dir string, ext []string, force bool) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("convert: %s (panic)\nstack: %s", r, debug.Stack())
@@ -58,7 +58,7 @@ func (c *Convert) Start(dir string, ext []string, force bool) (err error) {
// Start a fixed number of goroutines to convert files.
var wg sync.WaitGroup
var numWorkers = c.conf.IndexWorkers()
var numWorkers = w.conf.IndexWorkers()
wg.Add(numWorkers)
for i := 0; i < numWorkers; i++ {
go func() {
@@ -117,7 +117,7 @@ func (c *Convert) Start(dir string, ext []string, force bool) (err error) {
jobs <- ConvertJob{
force: force,
file: f,
convert: c,
convert: w,
}
return nil

View File

@@ -0,0 +1,55 @@
package photoprism
import (
"os/exec"
"github.com/photoprism/photoprism/pkg/media"
)
// ConvertCommand represents a command to be executed for converting a MediaFile.
// including any options to be used for this.
type ConvertCommand struct {
Cmd *exec.Cmd
Orientation media.Orientation
}
// String returns the conversion command as string e.g. for logging.
func (c *ConvertCommand) String() string {
if c.Cmd == nil {
return ""
}
return c.Cmd.String()
}
// WithOrientation sets the media Orientation after successful conversion.
func (c *ConvertCommand) WithOrientation(o media.Orientation) *ConvertCommand {
c.Orientation = media.ParseOrientation(o, c.Orientation)
return c
}
// ResetOrientation resets the media Orientation after successful conversion.
func (c *ConvertCommand) ResetOrientation() *ConvertCommand {
return c.WithOrientation(media.ResetOrientation)
}
// NewConvertCommand returns a new file converter command with default options.
func NewConvertCommand(cmd *exec.Cmd) *ConvertCommand {
if cmd == nil {
return nil
}
return &ConvertCommand{
Cmd: cmd, // File conversion command.
Orientation: media.KeepOrientation, // Keep the orientation by default.
}
}
// ConvertCommands represents a list of possible ConvertCommand commands for converting a MediaFile,
// sorted by priority.
type ConvertCommands []*ConvertCommand
// NewConvertCommands returns a new, empty list of ConvertCommand commands.
func NewConvertCommands() ConvertCommands {
return make(ConvertCommands, 0, 8)
}

View File

@@ -0,0 +1,42 @@
package photoprism
import (
"os/exec"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/media"
)
func TestNewConvertCommand(t *testing.T) {
t.Run("Nil", func(t *testing.T) {
assert.Nil(t, NewConvertCommand(nil))
})
t.Run("Default", func(t *testing.T) {
result := NewConvertCommand(
exec.Command("/usr/bin/sips", "-Z", "123", "-s", "format", "jpeg", "--out", "file.jpeg", "file.heic"),
)
assert.NotNil(t, result)
assert.NotNil(t, result.Cmd)
assert.Equal(t, "/usr/bin/sips -Z 123 -s format jpeg --out file.jpeg file.heic", result.String())
assert.Equal(t, media.KeepOrientation, result.Orientation)
})
t.Run("WithOrientation", func(t *testing.T) {
result := NewConvertCommand(
exec.Command("/usr/bin/sips", "-Z", "123", "-s", "format", "jpeg", "--out", "file.jpeg", "file.heic"),
)
result.WithOrientation(media.ResetOrientation)
assert.NotNil(t, result)
assert.NotNil(t, result.Cmd)
assert.Equal(t, "/usr/bin/sips -Z 123 -s format jpeg --out file.jpeg file.heic", result.String())
assert.Equal(t, media.ResetOrientation, result.Orientation)
})
}
func TestNewConvertCommands(t *testing.T) {
t.Run("Success", func(t *testing.T) {
result := NewConvertCommands()
assert.NotNil(t, result)
})
}

View File

@@ -14,14 +14,14 @@ import (
)
// FixJpeg tries to re-encode a broken JPEG and returns the cached image file.
func (c *Convert) FixJpeg(f *MediaFile, force bool) (*MediaFile, error) {
func (w *Convert) FixJpeg(f *MediaFile, force bool) (*MediaFile, error) {
if f == nil {
return nil, fmt.Errorf("convert: file is nil - you may have found a bug")
}
logName := clean.Log(f.RootRelName())
if c.conf.DisableImageMagick() || !c.imageMagickExclude.Allow(fs.ExtJPEG) {
if w.conf.DisableImageMagick() || !w.imageMagickExclude.Allow(fs.ExtJPEG) {
return nil, fmt.Errorf("convert: ImageMagick must be enabled to re-encode %s", logName)
}
@@ -39,7 +39,7 @@ func (c *Convert) FixJpeg(f *MediaFile, force bool) (*MediaFile, error) {
fileHash := f.Hash()
// Get cache path based on config and file hash.
cacheDir := c.conf.MediaFileCachePath(fileHash)
cacheDir := w.conf.MediaFileCachePath(fileHash)
// Compose cache filename.
cacheName := filepath.Join(cacheDir, fileHash+fs.ExtJPEG)
@@ -59,7 +59,7 @@ func (c *Convert) FixJpeg(f *MediaFile, force bool) (*MediaFile, error) {
}
}
fileName := f.RelName(c.conf.OriginalsPath())
fileName := f.RelName(w.conf.OriginalsPath())
// Publish file conversion event.
event.Publish("index.converting", event.Data{
@@ -72,10 +72,10 @@ func (c *Convert) FixJpeg(f *MediaFile, force bool) (*MediaFile, error) {
start := time.Now()
// Try ImageMagick for other image file formats if allowed.
quality := fmt.Sprintf("%d", c.conf.JpegQuality())
resize := fmt.Sprintf("%dx%d>", c.conf.JpegSize(), c.conf.JpegSize())
quality := fmt.Sprintf("%d", w.conf.JpegQuality())
resize := fmt.Sprintf("%dx%d>", w.conf.JpegSize(), w.conf.JpegSize())
args := []string{f.FileName(), "-flatten", "-resize", resize, "-quality", quality, cacheName}
cmd := exec.Command(c.conf.ImageMagickBin(), args...)
cmd := exec.Command(w.conf.ImageMagickBin(), args...)
if fs.FileExists(cacheName) {
return NewMediaFile(cacheName)
@@ -86,10 +86,10 @@ func (c *Convert) FixJpeg(f *MediaFile, force bool) (*MediaFile, error) {
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
cmd.Env = []string{
fmt.Sprintf("HOME=%s", c.conf.CmdCachePath()),
fmt.Sprintf("LD_LIBRARY_PATH=%s", c.conf.CmdLibPath()),
}
cmd.Env = append(cmd.Env, []string{
fmt.Sprintf("HOME=%s", w.conf.CmdCachePath()),
fmt.Sprintf("LD_LIBRARY_PATH=%s", w.conf.CmdLibPath()),
}...)
log.Infof("convert: re-encoding %s to %s (%s)", logName, clean.Log(filepath.Base(cacheName)), filepath.Base(cmd.Path))

View File

@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
@@ -16,10 +15,11 @@ import (
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
)
// ToImage converts a media file to a directly supported image file format.
func (c *Convert) ToImage(f *MediaFile, force bool) (*MediaFile, error) {
func (w *Convert) ToImage(f *MediaFile, force bool) (result *MediaFile, err error) {
if f == nil {
return nil, fmt.Errorf("convert: file is nil - you may have found a bug")
}
@@ -36,12 +36,10 @@ func (c *Convert) ToImage(f *MediaFile, force bool) (*MediaFile, error) {
return f, nil
}
var err error
imageName := fs.ImagePNG.FindFirst(f.FileName(), []string{c.conf.SidecarPath(), fs.PPHiddenPathname}, c.conf.OriginalsPath(), false)
imageName := fs.ImagePNG.FindFirst(f.FileName(), []string{w.conf.SidecarPath(), fs.PPHiddenPathname}, w.conf.OriginalsPath(), false)
if imageName == "" {
imageName = fs.ImageJPEG.FindFirst(f.FileName(), []string{c.conf.SidecarPath(), fs.PPHiddenPathname}, c.conf.OriginalsPath(), false)
imageName = fs.ImageJPEG.FindFirst(f.FileName(), []string{w.conf.SidecarPath(), fs.PPHiddenPathname}, w.conf.OriginalsPath(), false)
}
mediaFile, err := NewMediaFile(imageName)
@@ -58,19 +56,20 @@ func (c *Convert) ToImage(f *MediaFile, force bool) (*MediaFile, error) {
return mediaFile, nil
}
} else if f.IsVector() {
if !c.conf.VectorEnabled() {
if !w.conf.VectorEnabled() {
return nil, fmt.Errorf("convert: vector graphics support disabled (%s)", clean.Log(f.RootRelName()))
}
imageName, _ = fs.FileName(f.FileName(), c.conf.SidecarPath(), c.conf.OriginalsPath(), fs.ExtPNG)
imageName, _ = fs.FileName(f.FileName(), w.conf.SidecarPath(), w.conf.OriginalsPath(), fs.ExtPNG)
} else {
imageName, _ = fs.FileName(f.FileName(), c.conf.SidecarPath(), c.conf.OriginalsPath(), fs.ExtJPEG)
imageName, _ = fs.FileName(f.FileName(), w.conf.SidecarPath(), w.conf.OriginalsPath(), fs.ExtJPEG)
}
if !c.conf.SidecarWritable() {
if !w.conf.SidecarWritable() {
return nil, fmt.Errorf("convert: disabled in read-only mode (%s)", clean.Log(f.RootRelName()))
}
fileName := f.RelName(c.conf.OriginalsPath())
fileName := f.RelName(w.conf.OriginalsPath())
fileOrientation := media.KeepOrientation
xmpName := fs.SidecarXMP.Find(f.FileName(), false)
// Publish file conversion event.
@@ -109,16 +108,16 @@ func (c *Convert) ToImage(f *MediaFile, force bool) (*MediaFile, error) {
}
// Run external commands for other formats.
var cmds []*exec.Cmd
var cmds ConvertCommands
var useMutex bool
var expectedMime string
switch fs.LowerExt(imageName) {
case fs.ExtPNG:
cmds, useMutex, err = c.PngConvertCommands(f, imageName)
cmds, useMutex, err = w.PngConvertCommands(f, imageName)
expectedMime = fs.MimeTypePNG
case fs.ExtJPEG:
cmds, useMutex, err = c.JpegConvertCommands(f, imageName, xmpName)
cmds, useMutex, err = w.JpegConvertCommands(f, imageName, xmpName)
expectedMime = fs.MimeTypeJPEG
default:
return nil, fmt.Errorf("convert: unspported target format %s (%s)", fs.LowerExt(imageName), clean.Log(f.RootRelName()))
@@ -133,25 +132,27 @@ func (c *Convert) ToImage(f *MediaFile, force bool) (*MediaFile, error) {
if useMutex {
// Make sure only one command is executed at a time.
// See https://photo.stackexchange.com/questions/105969/darktable-cli-fails-because-of-locked-database-file
c.cmdMutex.Lock()
defer c.cmdMutex.Unlock()
w.cmdMutex.Lock()
defer w.cmdMutex.Unlock()
}
if fs.FileExists(imageName) {
if fs.FileExistsNotEmpty(imageName) {
return NewMediaFile(imageName)
}
// Try compatible converters.
for _, cmd := range cmds {
for _, c := range cmds {
// Fetch command output.
var out bytes.Buffer
var stderr bytes.Buffer
cmd := c.Cmd
cmd.Stdout = &out
cmd.Stderr = &stderr
cmd.Env = []string{
fmt.Sprintf("HOME=%s", c.conf.CmdCachePath()),
fmt.Sprintf("LD_LIBRARY_PATH=%s", c.conf.CmdLibPath()),
}
cmd.Env = append(cmd.Env, []string{
fmt.Sprintf("HOME=%s", w.conf.CmdCachePath()),
fmt.Sprintf("LD_LIBRARY_PATH=%s", w.conf.CmdLibPath()),
}...)
log.Infof("convert: converting %s to %s (%s)", clean.Log(filepath.Base(fileName)), clean.Log(filepath.Base(imageName)), filepath.Base(cmd.Path))
@@ -168,6 +169,7 @@ func (c *Convert) ToImage(f *MediaFile, force bool) (*MediaFile, error) {
continue
} else if fs.FileExistsNotEmpty(imageName) {
log.Infof("convert: %s created in %s (%s)", clean.Log(filepath.Base(imageName)), time.Since(start), filepath.Base(cmd.Path))
fileOrientation = c.Orientation
break
} else if res := out.Bytes(); len(res) < 512 || !mimetype.Detect(res).Is(expectedMime) {
continue
@@ -175,6 +177,8 @@ func (c *Convert) ToImage(f *MediaFile, force bool) (*MediaFile, error) {
log.Tracef("convert: %s (%s)", err, filepath.Base(cmd.Path))
continue
} else {
log.Infof("convert: %s created in %s (%s)", clean.Log(filepath.Base(imageName)), time.Since(start), filepath.Base(cmd.Path))
fileOrientation = c.Orientation
break
}
}
@@ -184,5 +188,18 @@ func (c *Convert) ToImage(f *MediaFile, force bool) (*MediaFile, error) {
return nil, err
}
return NewMediaFile(imageName)
// Create a MediaFile instance from the generated file.
if result, err = NewMediaFile(imageName); err != nil {
return result, err
}
// Change the Exif orientation of the generated file if required.
switch fileOrientation {
case media.ResetOrientation:
if err = result.ChangeOrientation(1); err != nil {
log.Warnf("convert: %s in %s (change orientation)", err, clean.Log(result.RootRelName()))
}
}
return result, nil
}

View File

@@ -9,9 +9,9 @@ import (
"github.com/photoprism/photoprism/internal/ffmpeg"
)
// JpegConvertCommands returns commands for converting a media file to JPEG, if possible.
func (c *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName string) (result []*exec.Cmd, useMutex bool, err error) {
result = make([]*exec.Cmd, 0, 2)
// JpegConvertCommands returns the supported commands for converting a MediaFile to JPEG, sorted by priority.
func (w *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName string) (result ConvertCommands, useMutex bool, err error) {
result = NewConvertCommands()
if f == nil {
return result, useMutex, fmt.Errorf("file is nil - you may have found a bug")
@@ -19,27 +19,34 @@ func (c *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName str
// Find conversion command depending on the file type and runtime environment.
fileExt := f.Extension()
maxSize := strconv.Itoa(c.conf.JpegSize())
maxSize := strconv.Itoa(w.conf.JpegSize())
// Apple Scriptable image processing system: https://ss64.com/osx/sips.html
if (f.IsRaw() || f.IsHEIF()) && c.conf.SipsEnabled() && c.sipsExclude.Allow(fileExt) {
result = append(result, exec.Command(c.conf.SipsBin(), "-Z", maxSize, "-s", "format", "jpeg", "--out", jpegName, f.FileName()))
if (f.IsRaw() || f.IsHEIF()) && w.conf.SipsEnabled() && w.sipsExclude.Allow(fileExt) {
result = append(result, NewConvertCommand(
exec.Command(w.conf.SipsBin(), "-Z", maxSize, "-s", "format", "jpeg", "--out", jpegName, f.FileName())),
)
}
// Extract a still image to be used as preview.
if f.IsAnimated() && !f.IsWebP() && c.conf.FFmpegEnabled() {
if f.IsAnimated() && !f.IsWebP() && w.conf.FFmpegEnabled() {
// Use "ffmpeg" to extract a JPEG still image from the video.
result = append(result, exec.Command(c.conf.FFmpegBin(), "-y", "-ss", ffmpeg.PreviewTimeOffset(f.Duration()), "-i", f.FileName(), "-vframes", "1", jpegName))
result = append(result, NewConvertCommand(
exec.Command(w.conf.FFmpegBin(), "-y", "-ss", ffmpeg.PreviewTimeOffset(f.Duration()), "-i", f.FileName(), "-vframes", "1", jpegName)),
)
}
// Use heif-convert for HEIC/HEIF and AVIF image files.
if (f.IsHEIC() || f.IsAVIF()) && c.conf.HeifConvertEnabled() {
result = append(result, exec.Command(c.conf.HeifConvertBin(), "-q", c.conf.JpegQuality().String(), f.FileName(), jpegName))
if (f.IsHEIC() || f.IsAVIF()) && w.conf.HeifConvertEnabled() {
result = append(result, NewConvertCommand(
exec.Command(w.conf.HeifConvertBin(), "-q", w.conf.JpegQuality().String(), f.FileName(), jpegName)).
WithOrientation(w.conf.HeifConvertOrientation()),
)
}
// RAW files may be concerted with Darktable and RawTherapee.
if f.IsRaw() && c.conf.RawEnabled() {
if c.conf.DarktableEnabled() && c.darktableExclude.Allow(fileExt) {
if f.IsRaw() && w.conf.RawEnabled() {
if w.conf.DarktableEnabled() && w.darktableExclude.Allow(fileExt) {
var args []string
// Set RAW, XMP, and JPEG filenames.
@@ -50,7 +57,7 @@ func (c *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName str
}
// Set RAW to JPEG conversion options.
if c.conf.RawPresets() {
if w.conf.RawPresets() {
useMutex = true // can run one instance only with presets enabled
args = append(args, "--width", maxSize, "--height", maxSize, "--hq", "true", "--upscale", "false")
} else {
@@ -69,37 +76,47 @@ func (c *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName str
args = append(args, "--cachedir", dir)
}
result = append(result, exec.Command(c.conf.DarktableBin(), args...))
result = append(result, NewConvertCommand(
exec.Command(w.conf.DarktableBin(), args...)),
)
}
if c.conf.RawTherapeeEnabled() && c.rawTherapeeExclude.Allow(fileExt) {
jpegQuality := fmt.Sprintf("-j%d", c.conf.JpegQuality())
if w.conf.RawTherapeeEnabled() && w.rawTherapeeExclude.Allow(fileExt) {
jpegQuality := fmt.Sprintf("-j%d", w.conf.JpegQuality())
profile := filepath.Join(conf.AssetsPath(), "profiles", "raw.pp3")
args := []string{"-o", jpegName, "-p", profile, "-s", "-d", jpegQuality, "-js3", "-b8", "-c", f.FileName()}
result = append(result, exec.Command(c.conf.RawTherapeeBin(), args...))
result = append(result, NewConvertCommand(
exec.Command(w.conf.RawTherapeeBin(), args...)),
)
}
}
// Extract preview image from DNG files.
if f.IsDNG() && c.conf.ExifToolEnabled() {
if f.IsDNG() && w.conf.ExifToolEnabled() {
// Example: exiftool -b -PreviewImage -w IMG_4691.DNG.jpg IMG_4691.DNG
result = append(result, exec.Command(c.conf.ExifToolBin(), "-q", "-q", "-b", "-PreviewImage", f.FileName()))
result = append(result, NewConvertCommand(
exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-PreviewImage", f.FileName())),
)
}
// Decode JPEG XL image if support is enabled.
if f.IsJpegXL() && c.conf.JpegXLEnabled() {
result = append(result, exec.Command(c.conf.JpegXLDecoderBin(), f.FileName(), jpegName))
if f.IsJpegXL() && w.conf.JpegXLEnabled() {
result = append(result, NewConvertCommand(
exec.Command(w.conf.JpegXLDecoderBin(), f.FileName(), jpegName)),
)
}
// Try ImageMagick for other image file formats if allowed.
if c.conf.ImageMagickEnabled() && c.imageMagickExclude.Allow(fileExt) &&
(f.IsImage() && !f.IsJpegXL() && !f.IsRaw() && !f.IsHEIF() || f.IsVector() && c.conf.VectorEnabled()) {
quality := fmt.Sprintf("%d", c.conf.JpegQuality())
resize := fmt.Sprintf("%dx%d>", c.conf.JpegSize(), c.conf.JpegSize())
if w.conf.ImageMagickEnabled() && w.imageMagickExclude.Allow(fileExt) &&
(f.IsImage() && !f.IsJpegXL() && !f.IsRaw() && !f.IsHEIF() || f.IsVector() && w.conf.VectorEnabled()) {
quality := fmt.Sprintf("%d", w.conf.JpegQuality())
resize := fmt.Sprintf("%dx%d>", w.conf.JpegSize(), w.conf.JpegSize())
args := []string{f.FileName(), "-flatten", "-resize", resize, "-quality", quality, jpegName}
result = append(result, exec.Command(c.conf.ImageMagickBin(), args...))
result = append(result, NewConvertCommand(
exec.Command(w.conf.ImageMagickBin(), args...)),
)
}
// No suitable converter found?

View File

@@ -9,8 +9,8 @@ import (
)
// PngConvertCommands returns commands for converting a media file to PNG, if possible.
func (c *Convert) PngConvertCommands(f *MediaFile, pngName string) (result []*exec.Cmd, useMutex bool, err error) {
result = make([]*exec.Cmd, 0, 2)
func (w *Convert) PngConvertCommands(f *MediaFile, pngName string) (result ConvertCommands, useMutex bool, err error) {
result = NewConvertCommands()
if f == nil {
return result, useMutex, fmt.Errorf("file is nil - you may have found a bug")
@@ -18,39 +18,52 @@ func (c *Convert) PngConvertCommands(f *MediaFile, pngName string) (result []*ex
// Find conversion command depending on the file type and runtime environment.
fileExt := f.Extension()
maxSize := strconv.Itoa(c.conf.PngSize())
maxSize := strconv.Itoa(w.conf.PngSize())
// Apple Scriptable image processing system: https://ss64.com/osx/sips.html
if (f.IsRaw() || f.IsHEIF()) && c.conf.SipsEnabled() && c.sipsExclude.Allow(fileExt) {
result = append(result, exec.Command(c.conf.SipsBin(), "-Z", maxSize, "-s", "format", "png", "--out", pngName, f.FileName()))
if (f.IsRaw() || f.IsHEIF()) && w.conf.SipsEnabled() && w.sipsExclude.Allow(fileExt) {
result = append(result, NewConvertCommand(
exec.Command(w.conf.SipsBin(), "-Z", maxSize, "-s", "format", "png", "--out", pngName, f.FileName())),
)
}
// Extract a video still image that can be used as preview.
if f.IsAnimated() && !f.IsWebP() && c.conf.FFmpegEnabled() {
if f.IsAnimated() && !f.IsWebP() && w.conf.FFmpegEnabled() {
// Use "ffmpeg" to extract a PNG still image from the video.
result = append(result, exec.Command(c.conf.FFmpegBin(), "-y", "-ss", ffmpeg.PreviewTimeOffset(f.Duration()), "-i", f.FileName(), "-vframes", "1", pngName))
result = append(result, NewConvertCommand(
exec.Command(w.conf.FFmpegBin(), "-y", "-ss", ffmpeg.PreviewTimeOffset(f.Duration()), "-i", f.FileName(), "-vframes", "1", pngName)),
)
}
// Use heif-convert for HEIC/HEIF and AVIF image files.
if (f.IsHEIC() || f.IsAVIF()) && c.conf.HeifConvertEnabled() {
result = append(result, exec.Command(c.conf.HeifConvertBin(), f.FileName(), pngName))
if (f.IsHEIC() || f.IsAVIF()) && w.conf.HeifConvertEnabled() {
result = append(result, NewConvertCommand(
exec.Command(w.conf.HeifConvertBin(), f.FileName(), pngName)).
WithOrientation(w.conf.HeifConvertOrientation()),
)
}
// Decode JPEG XL image if support is enabled.
if f.IsJpegXL() && c.conf.JpegXLEnabled() {
result = append(result, exec.Command(c.conf.JpegXLDecoderBin(), f.FileName(), pngName))
if f.IsJpegXL() && w.conf.JpegXLEnabled() {
result = append(result, NewConvertCommand(
exec.Command(w.conf.JpegXLDecoderBin(), f.FileName(), pngName)),
)
}
// SVG vector graphics can be converted with librsvg if installed,
// otherwise try to convert the media file with ImageMagick.
if c.conf.RsvgConvertEnabled() && f.IsSVG() {
if w.conf.RsvgConvertEnabled() && f.IsSVG() {
args := []string{"-a", "-f", "png", "-o", pngName, f.FileName()}
result = append(result, exec.Command(c.conf.RsvgConvertBin(), args...))
} else if c.conf.ImageMagickEnabled() && c.imageMagickExclude.Allow(fileExt) &&
(f.IsImage() && !f.IsJpegXL() && !f.IsRaw() && !f.IsHEIF() || f.IsVector() && c.conf.VectorEnabled()) {
resize := fmt.Sprintf("%dx%d>", c.conf.PngSize(), c.conf.PngSize())
result = append(result, NewConvertCommand(
exec.Command(w.conf.RsvgConvertBin(), args...)),
)
} else if w.conf.ImageMagickEnabled() && w.imageMagickExclude.Allow(fileExt) &&
(f.IsImage() && !f.IsJpegXL() && !f.IsRaw() && !f.IsHEIF() || f.IsVector() && w.conf.VectorEnabled()) {
resize := fmt.Sprintf("%dx%d>", w.conf.PngSize(), w.conf.PngSize())
args := []string{f.FileName(), "-flatten", "-resize", resize, pngName}
result = append(result, exec.Command(c.conf.ImageMagickBin(), args...))
result = append(result, NewConvertCommand(
exec.Command(w.conf.ImageMagickBin(), args...)),
)
}
// No suitable converter found?

View File

@@ -13,7 +13,7 @@ import (
)
// ToJson uses exiftool to export metadata to a json file.
func (c *Convert) ToJson(f *MediaFile, force bool) (jsonName string, err error) {
func (w *Convert) ToJson(f *MediaFile, force bool) (jsonName string, err error) {
if f == nil {
return "", fmt.Errorf("exiftool: file is nil - you may have found a bug")
}
@@ -30,14 +30,16 @@ func (c *Convert) ToJson(f *MediaFile, force bool) (jsonName string, err error)
log.Debugf("exiftool: extracting metadata from %s", clean.Log(f.RootRelName()))
cmd := exec.Command(c.conf.ExifToolBin(), "-n", "-m", "-api", "LargeFileSupport", "-j", f.FileName())
cmd := exec.Command(w.conf.ExifToolBin(), "-n", "-m", "-api", "LargeFileSupport", "-j", f.FileName())
// Fetch command output.
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
cmd.Env = []string{fmt.Sprintf("HOME=%s", c.conf.CmdCachePath())}
cmd.Env = append(cmd.Env, []string{
fmt.Sprintf("HOME=%s", w.conf.CmdCachePath()),
}...)
// Log exact command for debugging in trace mode.
log.Trace(cmd.String())

View File

@@ -23,19 +23,19 @@ func TestConvert_Start(t *testing.T) {
t.Skip("skipping test in short mode.")
}
conf := config.TestConfig()
c := config.TestConfig()
conf.InitializeTestData()
c.InitializeTestData()
convert := NewConvert(conf)
convert := NewConvert(c)
err := convert.Start(conf.ImportPath(), nil, false)
err := convert.Start(c.ImportPath(), nil, false)
if err != nil {
t.Fatal(err)
}
jpegFilename := filepath.Join(conf.SidecarPath(), conf.ImportPath(), "raw/canon_eos_6d.dng.jpg")
jpegFilename := filepath.Join(c.SidecarPath(), c.ImportPath(), "raw/canon_eos_6d.dng.jpg")
assert.True(t, fs.FileExists(jpegFilename), "Primary file was not found - is Darktable installed?")
@@ -51,13 +51,13 @@ func TestConvert_Start(t *testing.T) {
assert.Equal(t, "Canon EOS 6D", infoRaw.CameraModel, "UpdateCamera model should be Canon EOS M10")
existingJpegFilename := filepath.Join(conf.SidecarPath(), conf.ImportPath(), "/raw/IMG_2567.CR2.jpg")
existingJpegFilename := filepath.Join(c.SidecarPath(), c.ImportPath(), "/raw/IMG_2567.CR2.jpg")
oldHash := fs.Hash(existingJpegFilename)
_ = os.Remove(existingJpegFilename)
if err := convert.Start(conf.ImportPath(), nil, false); err != nil {
if err = convert.Start(c.ImportPath(), nil, false); err != nil {
t.Fatal(err)
}

View File

@@ -18,7 +18,7 @@ import (
)
// ToAvc converts a single video file to MPEG-4 AVC.
func (c *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force bool) (file *MediaFile, err error) {
func (w *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force bool) (file *MediaFile, err error) {
// Abort if the source media file is nil.
if f == nil {
return nil, fmt.Errorf("convert: file is nil - you may have found a bug")
@@ -36,9 +36,9 @@ func (c *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force
// Use .mp4 file extension for animated images and .avi for videos.
if f.IsAnimatedImage() {
avcName = fs.VideoMP4.FindFirst(f.FileName(), []string{c.conf.SidecarPath(), fs.PPHiddenPathname}, c.conf.OriginalsPath(), false)
avcName = fs.VideoMP4.FindFirst(f.FileName(), []string{w.conf.SidecarPath(), fs.PPHiddenPathname}, w.conf.OriginalsPath(), false)
} else {
avcName = fs.VideoAVC.FindFirst(f.FileName(), []string{c.conf.SidecarPath(), fs.PPHiddenPathname}, c.conf.OriginalsPath(), false)
avcName = fs.VideoAVC.FindFirst(f.FileName(), []string{w.conf.SidecarPath(), fs.PPHiddenPathname}, w.conf.OriginalsPath(), false)
}
mediaFile, err := NewMediaFile(avcName)
@@ -52,21 +52,21 @@ func (c *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force
}
// Check if the sidecar path is writeable, so a new AVC file can be created.
if !c.conf.SidecarWritable() {
if !w.conf.SidecarWritable() {
return nil, fmt.Errorf("convert: transcoding disabled in read-only mode (%s)", f.RootRelName())
}
// Get relative filename for logging.
relName := f.RelName(c.conf.OriginalsPath())
relName := f.RelName(w.conf.OriginalsPath())
// Use .mp4 file extension for animated images and .avi for videos.
if f.IsAnimatedImage() {
avcName, _ = fs.FileName(f.FileName(), c.conf.SidecarPath(), c.conf.OriginalsPath(), fs.ExtMP4)
avcName, _ = fs.FileName(f.FileName(), w.conf.SidecarPath(), w.conf.OriginalsPath(), fs.ExtMP4)
} else {
avcName, _ = fs.FileName(f.FileName(), c.conf.SidecarPath(), c.conf.OriginalsPath(), fs.ExtAVC)
avcName, _ = fs.FileName(f.FileName(), w.conf.SidecarPath(), w.conf.OriginalsPath(), fs.ExtAVC)
}
cmd, useMutex, err := c.AvcConvertCommand(f, avcName, encoder)
cmd, useMutex, err := w.AvcConvertCommand(f, avcName, encoder)
// Return if an error occurred.
if err != nil {
@@ -76,8 +76,8 @@ func (c *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force
// Make sure only one convert command runs at a time.
if useMutex && !noMutex {
c.cmdMutex.Lock()
defer c.cmdMutex.Unlock()
w.cmdMutex.Lock()
defer w.cmdMutex.Unlock()
}
// Check if target file already exists.
@@ -99,7 +99,9 @@ func (c *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
cmd.Env = []string{fmt.Sprintf("HOME=%s", c.conf.CmdCachePath())}
cmd.Env = append(cmd.Env, []string{
fmt.Sprintf("HOME=%s", w.conf.CmdCachePath()),
}...)
event.Publish("index.converting", event.Data{
"fileType": f.FileType(),
@@ -137,7 +139,7 @@ func (c *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force
// Try again using software encoder.
if encoder != ffmpeg.SoftwareEncoder {
return c.ToAvc(f, ffmpeg.SoftwareEncoder, true, false)
return w.ToAvc(f, ffmpeg.SoftwareEncoder, true, false)
} else {
return nil, err
}
@@ -151,7 +153,7 @@ func (c *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force
}
// AvcConvertCommand returns the command for converting video files to MPEG-4 AVC.
func (c *Convert) AvcConvertCommand(f *MediaFile, avcName string, encoder ffmpeg.AvcEncoder) (result *exec.Cmd, useMutex bool, err error) {
func (w *Convert) AvcConvertCommand(f *MediaFile, avcName string, encoder ffmpeg.AvcEncoder) (result *exec.Cmd, useMutex bool, err error) {
fileExt := f.Extension()
fileName := f.FileName()
@@ -163,13 +165,13 @@ func (c *Convert) AvcConvertCommand(f *MediaFile, avcName string, encoder ffmpeg
}
// Try to transcode animated WebP images with ImageMagick.
if c.conf.ImageMagickEnabled() && f.IsWebP() && c.imageMagickExclude.Allow(fileExt) {
return exec.Command(c.conf.ImageMagickBin(), f.FileName(), avcName), false, nil
if w.conf.ImageMagickEnabled() && f.IsWebP() && w.imageMagickExclude.Allow(fileExt) {
return exec.Command(w.conf.ImageMagickBin(), f.FileName(), avcName), false, nil
}
// Use FFmpeg to transcode all other media files to AVC.
var opt ffmpeg.Options
if opt, err = c.conf.FFmpegOptions(encoder, c.AvcBitrate(f)); err != nil {
if opt, err = w.conf.FFmpegOptions(encoder, w.AvcBitrate(f)); err != nil {
return nil, false, fmt.Errorf("convert: failed to transcode %s (%s)", clean.Log(f.BaseName()), err)
} else {
return ffmpeg.AvcConvertCommand(fileName, avcName, opt)
@@ -177,14 +179,14 @@ func (c *Convert) AvcConvertCommand(f *MediaFile, avcName string, encoder ffmpeg
}
// AvcBitrate returns the ideal AVC encoding bitrate in megabits per second.
func (c *Convert) AvcBitrate(f *MediaFile) string {
func (w *Convert) AvcBitrate(f *MediaFile) string {
const defaultBitrate = "8M"
if f == nil {
return defaultBitrate
}
limit := c.conf.FFmpegBitrate()
limit := w.conf.FFmpegBitrate()
quality := 12
bitrate := int(math.Ceil(float64(f.Width()*f.Height()*quality) / 1000000))

View File

@@ -217,7 +217,9 @@ func (m *MediaFile) ChangeOrientation(val int) (err error) {
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
cmd.Env = []string{fmt.Sprintf("HOME=%s", cnf.CmdCachePath())}
cmd.Env = append(cmd.Env, []string{
fmt.Sprintf("HOME=%s", cnf.CmdCachePath()),
}...)
// Log exact command for debugging in trace mode.
log.Trace(cmd.String())

30
pkg/media/orientation.go Normal file
View File

@@ -0,0 +1,30 @@
package media
import "strings"
// Orientation represents a orientation metadata option.
// see https://github.com/photoprism/photoprism/issues/4439
type Orientation = string
const (
KeepOrientation Orientation = "keep"
ResetOrientation Orientation = "reset"
)
// ParseOrientation returns the matching orientation metadata option.
func ParseOrientation(s string, defaultOrientation Orientation) Orientation {
if s == "" {
return defaultOrientation
}
s = strings.ToLower(strings.TrimSpace(s))
switch s {
case "keep":
return KeepOrientation
case "reset":
return ResetOrientation
default:
return defaultOrientation
}
}

View File

@@ -0,0 +1,24 @@
package media
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseOrientation(t *testing.T) {
t.Run("Default", func(t *testing.T) {
assert.Equal(t, KeepOrientation, ParseOrientation("foo", KeepOrientation))
assert.Equal(t, ResetOrientation, ParseOrientation("foo", ResetOrientation))
assert.Equal(t, ResetOrientation, ParseOrientation("", ResetOrientation))
assert.Equal(t, "", ParseOrientation("", ""))
})
t.Run("Keep", func(t *testing.T) {
result := ParseOrientation("KeEp", ResetOrientation)
assert.Equal(t, KeepOrientation, result)
})
t.Run("Reset", func(t *testing.T) {
result := ParseOrientation("reset", KeepOrientation)
assert.Equal(t, ResetOrientation, result)
})
}