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 {
binPath = path
// 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

@@ -17,183 +17,184 @@ import (
// Application code should retrieve option values via getter functions since they provide
// validation and return defaults if a value is empty.
type Options struct {
Name string `json:"-"`
About string `json:"-"`
Edition string `json:"-"`
Version string `json:"-"`
Copyright string `json:"-"`
PartnerID string `yaml:"-" json:"-" flag:"partner-id"`
AuthMode string `yaml:"AuthMode" json:"-" flag:"auth-mode"`
Public bool `yaml:"Public" json:"-" flag:"public"`
AdminUser string `yaml:"AdminUser" json:"-" flag:"admin-user"`
AdminPassword string `yaml:"AdminPassword" json:"-" flag:"admin-password"`
PasswordLength int `yaml:"PasswordLength" json:"-" flag:"password-length"`
PasswordResetUri string `yaml:"PasswordResetUri" json:"-" flag:"password-reset-uri"`
RegisterUri string `yaml:"-" json:"-" flag:"register-uri"`
LoginUri string `yaml:"-" json:"-" flag:"login-uri"`
OIDCUri string `yaml:"OIDCUri" json:"-" flag:"oidc-uri"`
OIDCClient string `yaml:"OIDCClient" json:"-" flag:"oidc-client"`
OIDCSecret string `yaml:"OIDCSecret" json:"-" flag:"oidc-secret"`
OIDCScopes string `yaml:"OIDCScopes" json:"-" flag:"oidc-scopes"`
OIDCProvider string `yaml:"OIDCProvider" json:"OIDCProvider" flag:"oidc-provider"`
OIDCIcon string `yaml:"OIDCIcon" json:"OIDCIcon" flag:"oidc-icon"`
OIDCRedirect bool `yaml:"OIDCRedirect" json:"OIDCRedirect" flag:"oidc-redirect"`
OIDCRegister bool `yaml:"OIDCRegister" json:"OIDCRegister" flag:"oidc-register"`
OIDCUsername string `yaml:"OIDCUsername" json:"-" flag:"oidc-username"`
OIDCDomain string `yaml:"-" json:"-" flag:"oidc-domain"`
OIDCRole string `yaml:"-" json:"-" flag:"oidc-role"`
OIDCWebDAV bool `yaml:"OIDCWebDAV" json:"-" flag:"oidc-webdav"`
DisableOIDC bool `yaml:"DisableOIDC" json:"DisableOIDC" flag:"disable-oidc"`
SessionMaxAge int64 `yaml:"SessionMaxAge" json:"-" flag:"session-maxage"`
SessionTimeout int64 `yaml:"SessionTimeout" json:"-" flag:"session-timeout"`
SessionCache int64 `yaml:"SessionCache" json:"-" flag:"session-cache"`
LogLevel string `yaml:"LogLevel" json:"-" flag:"log-level"`
Prod bool `yaml:"Prod" json:"Prod" flag:"prod"`
Debug bool `yaml:"Debug" json:"Debug" flag:"debug"`
Trace bool `yaml:"Trace" json:"Trace" flag:"trace"`
Test bool `yaml:"-" json:"Test,omitempty" flag:"test"`
Unsafe bool `yaml:"-" json:"-" flag:"unsafe"`
Demo bool `yaml:"-" json:"-" flag:"demo"`
Sponsor bool `yaml:"-" json:"-" flag:"sponsor"`
ConfigPath string `yaml:"ConfigPath" json:"-" flag:"config-path"`
DefaultsYaml string `json:"-" yaml:"-" flag:"defaults-yaml"`
OriginalsPath string `yaml:"OriginalsPath" json:"-" flag:"originals-path"`
OriginalsLimit int `yaml:"OriginalsLimit" json:"OriginalsLimit" flag:"originals-limit"`
ResolutionLimit int `yaml:"ResolutionLimit" json:"ResolutionLimit" flag:"resolution-limit"`
UsersPath string `yaml:"UsersPath" json:"-" flag:"users-path"`
StoragePath string `yaml:"StoragePath" json:"-" flag:"storage-path"`
ImportPath string `yaml:"ImportPath" json:"-" flag:"import-path"`
ImportDest string `yaml:"ImportDest" json:"-" flag:"import-dest"`
CachePath string `yaml:"CachePath" json:"-" flag:"cache-path"`
TempPath string `yaml:"TempPath" json:"-" flag:"temp-path"`
AssetsPath string `yaml:"AssetsPath" json:"-" flag:"assets-path"`
CustomAssetsPath string `yaml:"-" json:"-" flag:"custom-assets-path"`
SidecarPath string `yaml:"SidecarPath" json:"-" flag:"sidecar-path"`
SidecarYaml bool `yaml:"SidecarYaml" json:"SidecarYaml" flag:"sidecar-yaml" default:"true"`
BackupPath string `yaml:"BackupPath" json:"-" flag:"backup-path"`
BackupSchedule string `yaml:"BackupSchedule" json:"BackupSchedule" flag:"backup-schedule"`
BackupRetain int `yaml:"BackupRetain" json:"BackupRetain" flag:"backup-retain"`
BackupDatabase bool `yaml:"BackupDatabase" json:"BackupDatabase" flag:"backup-database" default:"true"`
BackupAlbums bool `yaml:"BackupAlbums" json:"BackupAlbums" flag:"backup-albums" default:"true"`
IndexWorkers int `yaml:"IndexWorkers" json:"IndexWorkers" flag:"index-workers"`
IndexSchedule string `yaml:"IndexSchedule" json:"IndexSchedule" flag:"index-schedule"`
WakeupInterval time.Duration `yaml:"WakeupInterval" json:"WakeupInterval" flag:"wakeup-interval"`
AutoIndex int `yaml:"AutoIndex" json:"AutoIndex" flag:"auto-index"`
AutoImport int `yaml:"AutoImport" json:"AutoImport" flag:"auto-import"`
ReadOnly bool `yaml:"ReadOnly" json:"ReadOnly" flag:"read-only"`
Experimental bool `yaml:"Experimental" json:"Experimental" flag:"experimental"`
DisableSettings bool `yaml:"DisableSettings" json:"-" flag:"disable-settings"`
DisableBackups bool `yaml:"DisableBackups" json:"DisableBackups" flag:"disable-backups"`
DisableRestart bool `yaml:"DisableRestart" json:"-" flag:"disable-restart"`
DisableWebDAV bool `yaml:"DisableWebDAV" json:"DisableWebDAV" flag:"disable-webdav"`
DisablePlaces bool `yaml:"DisablePlaces" json:"DisablePlaces" flag:"disable-places"`
DisableTensorFlow bool `yaml:"DisableTensorFlow" json:"DisableTensorFlow" flag:"disable-tensorflow"`
DisableFaces bool `yaml:"DisableFaces" json:"DisableFaces" flag:"disable-faces"`
DisableClassification bool `yaml:"DisableClassification" json:"DisableClassification" flag:"disable-classification"`
DisableFFmpeg bool `yaml:"DisableFFmpeg" json:"DisableFFmpeg" flag:"disable-ffmpeg"`
DisableExifTool bool `yaml:"DisableExifTool" json:"DisableExifTool" flag:"disable-exiftool"`
DisableVips bool `yaml:"DisableVips" json:"DisableVips" flag:"disable-vips"`
DisableSips bool `yaml:"DisableSips" json:"DisableSips" flag:"disable-sips"`
DisableDarktable bool `yaml:"DisableDarktable" json:"DisableDarktable" flag:"disable-darktable"`
DisableRawTherapee bool `yaml:"DisableRawTherapee" json:"DisableRawTherapee" flag:"disable-rawtherapee"`
DisableImageMagick bool `yaml:"DisableImageMagick" json:"DisableImageMagick" flag:"disable-imagemagick"`
DisableHeifConvert bool `yaml:"DisableHeifConvert" json:"DisableHeifConvert" flag:"disable-heifconvert"`
DisableVectors bool `yaml:"DisableVectors" json:"DisableVectors" flag:"disable-vectors"`
DisableJpegXL bool `yaml:"DisableJpegXL" json:"DisableJpegXL" flag:"disable-jpegxl"`
DisableRaw bool `yaml:"DisableRaw" json:"DisableRaw" flag:"disable-raw"`
RawPresets bool `yaml:"RawPresets" json:"RawPresets" flag:"raw-presets"`
ExifBruteForce bool `yaml:"ExifBruteForce" json:"ExifBruteForce" flag:"exif-bruteforce"`
DetectNSFW bool `yaml:"DetectNSFW" json:"DetectNSFW" flag:"detect-nsfw"`
UploadNSFW bool `yaml:"UploadNSFW" json:"-" flag:"upload-nsfw"`
DefaultLocale string `yaml:"DefaultLocale" json:"DefaultLocale" flag:"default-locale"`
DefaultTimezone string `yaml:"DefaultTimezone" json:"DefaultTimezone" flag:"default-timezone"`
DefaultTheme string `yaml:"DefaultTheme" json:"DefaultTheme" flag:"default-theme"`
AppName string `yaml:"AppName" json:"AppName" flag:"app-name"`
AppMode string `yaml:"AppMode" json:"AppMode" flag:"app-mode"`
AppIcon string `yaml:"AppIcon" json:"AppIcon" flag:"app-icon"`
AppColor string `yaml:"AppColor" json:"AppColor" flag:"app-color"`
LegalInfo string `yaml:"LegalInfo" json:"LegalInfo" flag:"legal-info"`
LegalUrl string `yaml:"LegalUrl" json:"LegalUrl" flag:"legal-url"`
WallpaperUri string `yaml:"WallpaperUri" json:"WallpaperUri" flag:"wallpaper-uri"`
SiteUrl string `yaml:"SiteUrl" json:"SiteUrl" flag:"site-url"`
SiteAuthor string `yaml:"SiteAuthor" json:"SiteAuthor" flag:"site-author"`
SiteTitle string `yaml:"SiteTitle" json:"SiteTitle" flag:"site-title"`
SiteCaption string `yaml:"SiteCaption" json:"SiteCaption" flag:"site-caption"`
SiteDescription string `yaml:"SiteDescription" json:"SiteDescription" flag:"site-description"`
SitePreview string `yaml:"SitePreview" json:"SitePreview" flag:"site-preview"`
CdnUrl string `yaml:"CdnUrl" json:"CdnUrl" flag:"cdn-url"`
CdnVideo bool `yaml:"CdnVideo" json:"CdnVideo" flag:"cdn-video"`
CORSOrigin string `yaml:"CORSOrigin" json:"-" flag:"cors-origin"`
CORSHeaders string `yaml:"CORSHeaders" json:"-" flag:"cors-headers"`
CORSMethods string `yaml:"CORSMethods" json:"-" flag:"cors-methods"`
HttpsProxy string `yaml:"HttpsProxy" json:"HttpsProxy" flag:"https-proxy"`
HttpsProxyInsecure bool `yaml:"HttpsProxyInsecure" json:"HttpsProxyInsecure" flag:"https-proxy-insecure"`
TrustedProxies []string `yaml:"TrustedProxies" json:"-" flag:"trusted-proxy"`
ProxyProtoHeaders []string `yaml:"ProxyProtoHeaders" json:"-" flag:"proxy-proto-header"`
ProxyProtoHttps []string `yaml:"ProxyProtoHttps" json:"-" flag:"proxy-proto-https"`
DisableTLS bool `yaml:"DisableTLS" json:"DisableTLS" flag:"disable-tls"`
DefaultTLS bool `yaml:"DefaultTLS" json:"DefaultTLS" flag:"default-tls"`
TLSEmail string `yaml:"TLSEmail" json:"TLSEmail" flag:"tls-email"`
TLSCert string `yaml:"TLSCert" json:"TLSCert" flag:"tls-cert"`
TLSKey string `yaml:"TLSKey" json:"TLSKey" flag:"tls-key"`
HttpMode string `yaml:"HttpMode" json:"-" flag:"http-mode"`
HttpCompression string `yaml:"HttpCompression" json:"-" flag:"http-compression"`
HttpCachePublic bool `yaml:"HttpCachePublic" json:"HttpCachePublic" flag:"http-cache-public"`
HttpCacheMaxAge int `yaml:"HttpCacheMaxAge" json:"HttpCacheMaxAge" flag:"http-cache-maxage"`
HttpVideoMaxAge int `yaml:"HttpVideoMaxAge" json:"HttpVideoMaxAge" flag:"http-video-maxage"`
HttpHost string `yaml:"HttpHost" json:"-" flag:"http-host"`
HttpPort int `yaml:"HttpPort" json:"-" flag:"http-port"`
HttpSocket string `yaml:"-" json:"-" flag:"-"`
DatabaseDriver string `yaml:"DatabaseDriver" json:"-" flag:"database-driver"`
DatabaseDsn string `yaml:"DatabaseDsn" json:"-" flag:"database-dsn"`
DatabaseName string `yaml:"DatabaseName" json:"-" flag:"database-name"`
DatabaseServer string `yaml:"DatabaseServer" json:"-" flag:"database-server"`
DatabaseUser string `yaml:"DatabaseUser" json:"-" flag:"database-user"`
DatabasePassword string `yaml:"DatabasePassword" json:"-" flag:"database-password"`
DatabaseTimeout int `yaml:"DatabaseTimeout" json:"-" flag:"database-timeout"`
DatabaseConns int `yaml:"DatabaseConns" json:"-" flag:"database-conns"`
DatabaseConnsIdle int `yaml:"DatabaseConnsIdle" json:"-" flag:"database-conns-idle"`
FFmpegBin string `yaml:"FFmpegBin" json:"-" flag:"ffmpeg-bin"`
FFmpegEncoder string `yaml:"FFmpegEncoder" json:"FFmpegEncoder" flag:"ffmpeg-encoder"`
FFmpegSize int `yaml:"FFmpegSize" json:"FFmpegSize" flag:"ffmpeg-size"`
FFmpegBitrate int `yaml:"FFmpegBitrate" json:"FFmpegBitrate" flag:"ffmpeg-bitrate"`
FFmpegMapVideo string `yaml:"FFmpegMapVideo" json:"FFmpegMapVideo" flag:"ffmpeg-map-video"`
FFmpegMapAudio string `yaml:"FFmpegMapAudio" json:"FFmpegMapAudio" flag:"ffmpeg-map-audio"`
ExifToolBin string `yaml:"ExifToolBin" json:"-" flag:"exiftool-bin"`
SipsBin string `yaml:"SipsBin" json:"-" flag:"sips-bin"`
SipsExclude string `yaml:"SipsExclude" json:"-" flag:"sips-exclude"`
DarktableBin string `yaml:"DarktableBin" json:"-" flag:"darktable-bin"`
DarktableCachePath string `yaml:"DarktableCachePath" json:"-" flag:"darktable-cache-path"`
DarktableConfigPath string `yaml:"DarktableConfigPath" json:"-" flag:"darktable-config-path"`
DarktableExclude string `yaml:"DarktableExclude" json:"-" flag:"darktable-exclude"`
RawTherapeeBin string `yaml:"RawTherapeeBin" json:"-" flag:"rawtherapee-bin"`
RawTherapeeExclude string `yaml:"RawTherapeeExclude" json:"-" flag:"rawtherapee-exclude"`
ImageMagickBin string `yaml:"ImageMagickBin" json:"-" flag:"imagemagick-bin"`
ImageMagickExclude string `yaml:"ImageMagickExclude" json:"-" flag:"imagemagick-exclude"`
HeifConvertBin string `yaml:"HeifConvertBin" json:"-" flag:"heifconvert-bin"`
RsvgConvertBin string `yaml:"RsvgConvertBin" json:"-" flag:"rsvgconvert-bin"`
DownloadToken string `yaml:"DownloadToken" json:"-" flag:"download-token"`
PreviewToken string `yaml:"PreviewToken" json:"-" flag:"preview-token"`
ThumbLibrary string `yaml:"ThumbLibrary" json:"ThumbLibrary" flag:"thumb-library"`
ThumbColor string `yaml:"ThumbColor" json:"ThumbColor" flag:"thumb-color"`
ThumbFilter string `yaml:"ThumbFilter" json:"ThumbFilter" flag:"thumb-filter"`
ThumbSize int `yaml:"ThumbSize" json:"ThumbSize" flag:"thumb-size"`
ThumbSizeUncached int `yaml:"ThumbSizeUncached" json:"ThumbSizeUncached" flag:"thumb-size-uncached"`
ThumbUncached bool `yaml:"ThumbUncached" json:"ThumbUncached" flag:"thumb-uncached"`
JpegQuality int `yaml:"JpegQuality" json:"JpegQuality" flag:"jpeg-quality"`
JpegSize int `yaml:"JpegSize" json:"JpegSize" flag:"jpeg-size"`
PngSize int `yaml:"PngSize" json:"PngSize" flag:"png-size"`
FaceSize int `yaml:"-" json:"-" flag:"face-size"`
FaceScore float64 `yaml:"-" json:"-" flag:"face-score"`
FaceOverlap int `yaml:"-" json:"-" flag:"face-overlap"`
FaceClusterSize int `yaml:"-" json:"-" flag:"face-cluster-size"`
FaceClusterScore int `yaml:"-" json:"-" flag:"face-cluster-score"`
FaceClusterCore int `yaml:"-" json:"-" flag:"face-cluster-core"`
FaceClusterDist float64 `yaml:"-" json:"-" flag:"face-cluster-dist"`
FaceMatchDist float64 `yaml:"-" json:"-" flag:"face-match-dist"`
PIDFilename string `yaml:"PIDFilename" json:"-" flag:"pid-filename"`
LogFilename string `yaml:"LogFilename" json:"-" flag:"log-filename"`
DetachServer bool `yaml:"DetachServer" json:"-" flag:"detach-server"`
Name string `json:"-"`
About string `json:"-"`
Edition string `json:"-"`
Version string `json:"-"`
Copyright string `json:"-"`
PartnerID string `yaml:"-" json:"-" flag:"partner-id"`
AuthMode string `yaml:"AuthMode" json:"-" flag:"auth-mode"`
Public bool `yaml:"Public" json:"-" flag:"public"`
AdminUser string `yaml:"AdminUser" json:"-" flag:"admin-user"`
AdminPassword string `yaml:"AdminPassword" json:"-" flag:"admin-password"`
PasswordLength int `yaml:"PasswordLength" json:"-" flag:"password-length"`
PasswordResetUri string `yaml:"PasswordResetUri" json:"-" flag:"password-reset-uri"`
RegisterUri string `yaml:"-" json:"-" flag:"register-uri"`
LoginUri string `yaml:"-" json:"-" flag:"login-uri"`
OIDCUri string `yaml:"OIDCUri" json:"-" flag:"oidc-uri"`
OIDCClient string `yaml:"OIDCClient" json:"-" flag:"oidc-client"`
OIDCSecret string `yaml:"OIDCSecret" json:"-" flag:"oidc-secret"`
OIDCScopes string `yaml:"OIDCScopes" json:"-" flag:"oidc-scopes"`
OIDCProvider string `yaml:"OIDCProvider" json:"OIDCProvider" flag:"oidc-provider"`
OIDCIcon string `yaml:"OIDCIcon" json:"OIDCIcon" flag:"oidc-icon"`
OIDCRedirect bool `yaml:"OIDCRedirect" json:"OIDCRedirect" flag:"oidc-redirect"`
OIDCRegister bool `yaml:"OIDCRegister" json:"OIDCRegister" flag:"oidc-register"`
OIDCUsername string `yaml:"OIDCUsername" json:"-" flag:"oidc-username"`
OIDCDomain string `yaml:"-" json:"-" flag:"oidc-domain"`
OIDCRole string `yaml:"-" json:"-" flag:"oidc-role"`
OIDCWebDAV bool `yaml:"OIDCWebDAV" json:"-" flag:"oidc-webdav"`
DisableOIDC bool `yaml:"DisableOIDC" json:"DisableOIDC" flag:"disable-oidc"`
SessionMaxAge int64 `yaml:"SessionMaxAge" json:"-" flag:"session-maxage"`
SessionTimeout int64 `yaml:"SessionTimeout" json:"-" flag:"session-timeout"`
SessionCache int64 `yaml:"SessionCache" json:"-" flag:"session-cache"`
LogLevel string `yaml:"LogLevel" json:"-" flag:"log-level"`
Prod bool `yaml:"Prod" json:"Prod" flag:"prod"`
Debug bool `yaml:"Debug" json:"Debug" flag:"debug"`
Trace bool `yaml:"Trace" json:"Trace" flag:"trace"`
Test bool `yaml:"-" json:"Test,omitempty" flag:"test"`
Unsafe bool `yaml:"-" json:"-" flag:"unsafe"`
Demo bool `yaml:"-" json:"-" flag:"demo"`
Sponsor bool `yaml:"-" json:"-" flag:"sponsor"`
ConfigPath string `yaml:"ConfigPath" json:"-" flag:"config-path"`
DefaultsYaml string `json:"-" yaml:"-" flag:"defaults-yaml"`
OriginalsPath string `yaml:"OriginalsPath" json:"-" flag:"originals-path"`
OriginalsLimit int `yaml:"OriginalsLimit" json:"OriginalsLimit" flag:"originals-limit"`
ResolutionLimit int `yaml:"ResolutionLimit" json:"ResolutionLimit" flag:"resolution-limit"`
UsersPath string `yaml:"UsersPath" json:"-" flag:"users-path"`
StoragePath string `yaml:"StoragePath" json:"-" flag:"storage-path"`
ImportPath string `yaml:"ImportPath" json:"-" flag:"import-path"`
ImportDest string `yaml:"ImportDest" json:"-" flag:"import-dest"`
CachePath string `yaml:"CachePath" json:"-" flag:"cache-path"`
TempPath string `yaml:"TempPath" json:"-" flag:"temp-path"`
AssetsPath string `yaml:"AssetsPath" json:"-" flag:"assets-path"`
CustomAssetsPath string `yaml:"-" json:"-" flag:"custom-assets-path"`
SidecarPath string `yaml:"SidecarPath" json:"-" flag:"sidecar-path"`
SidecarYaml bool `yaml:"SidecarYaml" json:"SidecarYaml" flag:"sidecar-yaml" default:"true"`
BackupPath string `yaml:"BackupPath" json:"-" flag:"backup-path"`
BackupSchedule string `yaml:"BackupSchedule" json:"BackupSchedule" flag:"backup-schedule"`
BackupRetain int `yaml:"BackupRetain" json:"BackupRetain" flag:"backup-retain"`
BackupDatabase bool `yaml:"BackupDatabase" json:"BackupDatabase" flag:"backup-database" default:"true"`
BackupAlbums bool `yaml:"BackupAlbums" json:"BackupAlbums" flag:"backup-albums" default:"true"`
IndexWorkers int `yaml:"IndexWorkers" json:"IndexWorkers" flag:"index-workers"`
IndexSchedule string `yaml:"IndexSchedule" json:"IndexSchedule" flag:"index-schedule"`
WakeupInterval time.Duration `yaml:"WakeupInterval" json:"WakeupInterval" flag:"wakeup-interval"`
AutoIndex int `yaml:"AutoIndex" json:"AutoIndex" flag:"auto-index"`
AutoImport int `yaml:"AutoImport" json:"AutoImport" flag:"auto-import"`
ReadOnly bool `yaml:"ReadOnly" json:"ReadOnly" flag:"read-only"`
Experimental bool `yaml:"Experimental" json:"Experimental" flag:"experimental"`
DisableSettings bool `yaml:"DisableSettings" json:"-" flag:"disable-settings"`
DisableBackups bool `yaml:"DisableBackups" json:"DisableBackups" flag:"disable-backups"`
DisableRestart bool `yaml:"DisableRestart" json:"-" flag:"disable-restart"`
DisableWebDAV bool `yaml:"DisableWebDAV" json:"DisableWebDAV" flag:"disable-webdav"`
DisablePlaces bool `yaml:"DisablePlaces" json:"DisablePlaces" flag:"disable-places"`
DisableTensorFlow bool `yaml:"DisableTensorFlow" json:"DisableTensorFlow" flag:"disable-tensorflow"`
DisableFaces bool `yaml:"DisableFaces" json:"DisableFaces" flag:"disable-faces"`
DisableClassification bool `yaml:"DisableClassification" json:"DisableClassification" flag:"disable-classification"`
DisableFFmpeg bool `yaml:"DisableFFmpeg" json:"DisableFFmpeg" flag:"disable-ffmpeg"`
DisableExifTool bool `yaml:"DisableExifTool" json:"DisableExifTool" flag:"disable-exiftool"`
DisableVips bool `yaml:"DisableVips" json:"DisableVips" flag:"disable-vips"`
DisableSips bool `yaml:"DisableSips" json:"DisableSips" flag:"disable-sips"`
DisableDarktable bool `yaml:"DisableDarktable" json:"DisableDarktable" flag:"disable-darktable"`
DisableRawTherapee bool `yaml:"DisableRawTherapee" json:"DisableRawTherapee" flag:"disable-rawtherapee"`
DisableImageMagick bool `yaml:"DisableImageMagick" json:"DisableImageMagick" flag:"disable-imagemagick"`
DisableHeifConvert bool `yaml:"DisableHeifConvert" json:"DisableHeifConvert" flag:"disable-heifconvert"`
DisableVectors bool `yaml:"DisableVectors" json:"DisableVectors" flag:"disable-vectors"`
DisableJpegXL bool `yaml:"DisableJpegXL" json:"DisableJpegXL" flag:"disable-jpegxl"`
DisableRaw bool `yaml:"DisableRaw" json:"DisableRaw" flag:"disable-raw"`
RawPresets bool `yaml:"RawPresets" json:"RawPresets" flag:"raw-presets"`
ExifBruteForce bool `yaml:"ExifBruteForce" json:"ExifBruteForce" flag:"exif-bruteforce"`
DetectNSFW bool `yaml:"DetectNSFW" json:"DetectNSFW" flag:"detect-nsfw"`
UploadNSFW bool `yaml:"UploadNSFW" json:"-" flag:"upload-nsfw"`
DefaultLocale string `yaml:"DefaultLocale" json:"DefaultLocale" flag:"default-locale"`
DefaultTimezone string `yaml:"DefaultTimezone" json:"DefaultTimezone" flag:"default-timezone"`
DefaultTheme string `yaml:"DefaultTheme" json:"DefaultTheme" flag:"default-theme"`
AppName string `yaml:"AppName" json:"AppName" flag:"app-name"`
AppMode string `yaml:"AppMode" json:"AppMode" flag:"app-mode"`
AppIcon string `yaml:"AppIcon" json:"AppIcon" flag:"app-icon"`
AppColor string `yaml:"AppColor" json:"AppColor" flag:"app-color"`
LegalInfo string `yaml:"LegalInfo" json:"LegalInfo" flag:"legal-info"`
LegalUrl string `yaml:"LegalUrl" json:"LegalUrl" flag:"legal-url"`
WallpaperUri string `yaml:"WallpaperUri" json:"WallpaperUri" flag:"wallpaper-uri"`
SiteUrl string `yaml:"SiteUrl" json:"SiteUrl" flag:"site-url"`
SiteAuthor string `yaml:"SiteAuthor" json:"SiteAuthor" flag:"site-author"`
SiteTitle string `yaml:"SiteTitle" json:"SiteTitle" flag:"site-title"`
SiteCaption string `yaml:"SiteCaption" json:"SiteCaption" flag:"site-caption"`
SiteDescription string `yaml:"SiteDescription" json:"SiteDescription" flag:"site-description"`
SitePreview string `yaml:"SitePreview" json:"SitePreview" flag:"site-preview"`
CdnUrl string `yaml:"CdnUrl" json:"CdnUrl" flag:"cdn-url"`
CdnVideo bool `yaml:"CdnVideo" json:"CdnVideo" flag:"cdn-video"`
CORSOrigin string `yaml:"CORSOrigin" json:"-" flag:"cors-origin"`
CORSHeaders string `yaml:"CORSHeaders" json:"-" flag:"cors-headers"`
CORSMethods string `yaml:"CORSMethods" json:"-" flag:"cors-methods"`
HttpsProxy string `yaml:"HttpsProxy" json:"HttpsProxy" flag:"https-proxy"`
HttpsProxyInsecure bool `yaml:"HttpsProxyInsecure" json:"HttpsProxyInsecure" flag:"https-proxy-insecure"`
TrustedProxies []string `yaml:"TrustedProxies" json:"-" flag:"trusted-proxy"`
ProxyProtoHeaders []string `yaml:"ProxyProtoHeaders" json:"-" flag:"proxy-proto-header"`
ProxyProtoHttps []string `yaml:"ProxyProtoHttps" json:"-" flag:"proxy-proto-https"`
DisableTLS bool `yaml:"DisableTLS" json:"DisableTLS" flag:"disable-tls"`
DefaultTLS bool `yaml:"DefaultTLS" json:"DefaultTLS" flag:"default-tls"`
TLSEmail string `yaml:"TLSEmail" json:"TLSEmail" flag:"tls-email"`
TLSCert string `yaml:"TLSCert" json:"TLSCert" flag:"tls-cert"`
TLSKey string `yaml:"TLSKey" json:"TLSKey" flag:"tls-key"`
HttpMode string `yaml:"HttpMode" json:"-" flag:"http-mode"`
HttpCompression string `yaml:"HttpCompression" json:"-" flag:"http-compression"`
HttpCachePublic bool `yaml:"HttpCachePublic" json:"HttpCachePublic" flag:"http-cache-public"`
HttpCacheMaxAge int `yaml:"HttpCacheMaxAge" json:"HttpCacheMaxAge" flag:"http-cache-maxage"`
HttpVideoMaxAge int `yaml:"HttpVideoMaxAge" json:"HttpVideoMaxAge" flag:"http-video-maxage"`
HttpHost string `yaml:"HttpHost" json:"-" flag:"http-host"`
HttpPort int `yaml:"HttpPort" json:"-" flag:"http-port"`
HttpSocket string `yaml:"-" json:"-" flag:"-"`
DatabaseDriver string `yaml:"DatabaseDriver" json:"-" flag:"database-driver"`
DatabaseDsn string `yaml:"DatabaseDsn" json:"-" flag:"database-dsn"`
DatabaseName string `yaml:"DatabaseName" json:"-" flag:"database-name"`
DatabaseServer string `yaml:"DatabaseServer" json:"-" flag:"database-server"`
DatabaseUser string `yaml:"DatabaseUser" json:"-" flag:"database-user"`
DatabasePassword string `yaml:"DatabasePassword" json:"-" flag:"database-password"`
DatabaseTimeout int `yaml:"DatabaseTimeout" json:"-" flag:"database-timeout"`
DatabaseConns int `yaml:"DatabaseConns" json:"-" flag:"database-conns"`
DatabaseConnsIdle int `yaml:"DatabaseConnsIdle" json:"-" flag:"database-conns-idle"`
FFmpegBin string `yaml:"FFmpegBin" json:"-" flag:"ffmpeg-bin"`
FFmpegEncoder string `yaml:"FFmpegEncoder" json:"FFmpegEncoder" flag:"ffmpeg-encoder"`
FFmpegSize int `yaml:"FFmpegSize" json:"FFmpegSize" flag:"ffmpeg-size"`
FFmpegBitrate int `yaml:"FFmpegBitrate" json:"FFmpegBitrate" flag:"ffmpeg-bitrate"`
FFmpegMapVideo string `yaml:"FFmpegMapVideo" json:"FFmpegMapVideo" flag:"ffmpeg-map-video"`
FFmpegMapAudio string `yaml:"FFmpegMapAudio" json:"FFmpegMapAudio" flag:"ffmpeg-map-audio"`
ExifToolBin string `yaml:"ExifToolBin" json:"-" flag:"exiftool-bin"`
SipsBin string `yaml:"SipsBin" json:"-" flag:"sips-bin"`
SipsExclude string `yaml:"SipsExclude" json:"-" flag:"sips-exclude"`
DarktableBin string `yaml:"DarktableBin" json:"-" flag:"darktable-bin"`
DarktableCachePath string `yaml:"DarktableCachePath" json:"-" flag:"darktable-cache-path"`
DarktableConfigPath string `yaml:"DarktableConfigPath" json:"-" flag:"darktable-config-path"`
DarktableExclude string `yaml:"DarktableExclude" json:"-" flag:"darktable-exclude"`
RawTherapeeBin string `yaml:"RawTherapeeBin" json:"-" flag:"rawtherapee-bin"`
RawTherapeeExclude string `yaml:"RawTherapeeExclude" json:"-" flag:"rawtherapee-exclude"`
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"`
ThumbLibrary string `yaml:"ThumbLibrary" json:"ThumbLibrary" flag:"thumb-library"`
ThumbColor string `yaml:"ThumbColor" json:"ThumbColor" flag:"thumb-color"`
ThumbFilter string `yaml:"ThumbFilter" json:"ThumbFilter" flag:"thumb-filter"`
ThumbSize int `yaml:"ThumbSize" json:"ThumbSize" flag:"thumb-size"`
ThumbSizeUncached int `yaml:"ThumbSizeUncached" json:"ThumbSizeUncached" flag:"thumb-size-uncached"`
ThumbUncached bool `yaml:"ThumbUncached" json:"ThumbUncached" flag:"thumb-uncached"`
JpegQuality int `yaml:"JpegQuality" json:"JpegQuality" flag:"jpeg-quality"`
JpegSize int `yaml:"JpegSize" json:"JpegSize" flag:"jpeg-size"`
PngSize int `yaml:"PngSize" json:"PngSize" flag:"png-size"`
FaceSize int `yaml:"-" json:"-" flag:"face-size"`
FaceScore float64 `yaml:"-" json:"-" flag:"face-score"`
FaceOverlap int `yaml:"-" json:"-" flag:"face-overlap"`
FaceClusterSize int `yaml:"-" json:"-" flag:"face-cluster-size"`
FaceClusterScore int `yaml:"-" json:"-" flag:"face-cluster-score"`
FaceClusterCore int `yaml:"-" json:"-" flag:"face-cluster-core"`
FaceClusterDist float64 `yaml:"-" json:"-" flag:"face-cluster-dist"`
FaceMatchDist float64 `yaml:"-" json:"-" flag:"face-match-dist"`
PIDFilename string `yaml:"PIDFilename" json:"-" flag:"pid-filename"`
LogFilename string `yaml:"LogFilename" json:"-" flag:"log-filename"`
DetachServer bool `yaml:"DetachServer" json:"-" flag:"detach-server"`
}
// NewOptions creates a new configuration entity by using two methods:

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)
})
}