mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
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:
2
go.mod
2
go.mod
@@ -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
4
go.sum
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
113
internal/config/config_media_test.go
Normal file
113
internal/config/config_media_test.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
55
internal/photoprism/convert_command.go
Normal file
55
internal/photoprism/convert_command.go
Normal 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)
|
||||
}
|
||||
42
internal/photoprism/convert_command_test.go
Normal file
42
internal/photoprism/convert_command_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
30
pkg/media/orientation.go
Normal 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
|
||||
}
|
||||
}
|
||||
24
pkg/media/orientation_test.go
Normal file
24
pkg/media/orientation_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user