diff --git a/Makefile b/Makefile index b55c7c268..8e8690a0a 100644 --- a/Makefile +++ b/Makefile @@ -50,6 +50,7 @@ test-go: reset-sqlite run-test-go test-pkg: reset-sqlite run-test-pkg test-api: reset-sqlite run-test-api test-commands: reset-sqlite run-test-commands +test-photoprism: reset-sqlite run-test-photoprism test-short: reset-sqlite run-test-short test-mariadb: reset-acceptance run-test-mariadb acceptance-run-chromium: storage/acceptance acceptance-auth-sqlite-restart acceptance-auth acceptance-auth-sqlite-stop acceptance-sqlite-restart acceptance acceptance-sqlite-stop @@ -263,6 +264,9 @@ run-test-api: run-test-commands: $(info Running all CLI command tests...) $(GOTEST) -parallel 2 -count 1 -cpu 2 -tags slow -timeout 20m ./internal/commands/... +run-test-photoprism: + $(info Running all Go tests in "/internal/photoprism"...) + $(GOTEST) -parallel 2 -count 1 -cpu 2 -tags slow -timeout 20m ./internal/photoprism/... test-parallel: $(info Running all Go tests in parallel mode...) $(GOTEST) -parallel 2 -count 1 -cpu 2 -tags slow -timeout 20m ./pkg/... ./internal/... diff --git a/go.mod b/go.mod index bd11e9659..be2153ca4 100644 --- a/go.mod +++ b/go.mod @@ -13,15 +13,14 @@ require ( github.com/dsoprea/go-png-image-structure/v2 v2.0.0-20210512210324-29b889a6093d github.com/dsoprea/go-tiff-image-structure/v2 v2.0.0-20221003165014-8ecc4f52edca github.com/dustin/go-humanize v1.0.0 - github.com/esimov/pigo v1.4.5 + github.com/esimov/pigo v1.4.6 github.com/gin-contrib/gzip v0.0.6 github.com/gin-gonic/gin v1.8.1 github.com/go-playground/validator/v10 v10.11.1 // indirect github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 github.com/google/open-location-code/go v0.0.0-20220922185916-75f4f40254f8 github.com/gorilla/websocket v1.5.0 - github.com/gosimple/slug v1.13.0 - github.com/h2non/filetype v1.1.3 + github.com/gosimple/slug v1.13.1 github.com/jinzhu/gorm v1.9.16 github.com/jinzhu/inflection v1.0.0 github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect @@ -48,8 +47,8 @@ require ( github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6 github.com/urfave/cli v1.22.10 go4.org v0.0.0-20201209231011-d4a079459e60 // indirect - golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be - golang.org/x/net v0.0.0-20221002022538-bcab6841153b + golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b + golang.org/x/net v0.0.0-20221004154528-8021a29435af gonum.org/v1/gonum v0.12.0 gopkg.in/photoprism/go-tz.v2 v2.1.1 gopkg.in/yaml.v2 v2.4.0 @@ -79,6 +78,8 @@ require ( google.golang.org/protobuf v1.28.1 // indirect ) +require github.com/gabriel-vasile/mimetype v1.4.1 + require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect diff --git a/go.sum b/go.sum index 7e0d02d30..a5775a700 100644 --- a/go.sum +++ b/go.sum @@ -153,11 +153,13 @@ github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go. github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= -github.com/esimov/pigo v1.4.5 h1:ySG0QqMh02VNALvHnx04L1ScRu66N6XA5vLLga8GiLg= -github.com/esimov/pigo v1.4.5/go.mod h1:SGkOUpm4wlEmQQJKlaymAkThY8/8iP+XE0gFo7g8G6w= +github.com/esimov/pigo v1.4.6 h1:wpB9FstbqeGP/CZP+nTR52tUJe7XErq8buG+k4xCXlw= +github.com/esimov/pigo v1.4.6/go.mod h1:uqj9Y3+3IRYhFK071rxz1QYq0ePhA6+R9jrUZavi46M= github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q= +github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= @@ -299,13 +301,11 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gosimple/slug v1.13.0 h1:w4W2sU2a/JcAkI+LN316Cn/NE4CXopoXto9aloYTic0= -github.com/gosimple/slug v1.13.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= +github.com/gosimple/slug v1.13.1 h1:bQ+kpX9Qa6tHRaK+fZR0A0M2Kd7Pa5eHPPsb1JpHD+Q= +github.com/gosimple/slug v1.13.1/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= -github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -479,8 +479,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be h1:fmw3UbQh+nxngCAHrDCCztao/kbYFnWjoqop8dHx05A= -golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b h1:huxqepDufQpLLIRXiVkTvnxrzJlpwmIWAObmcCcUFr0= +golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -583,8 +583,9 @@ golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20221002022538-bcab6841153b h1:6e93nYa3hNqAvLr0pD4PN1fFS+gKzp2zAXqrnTCstqU= golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.0.0-20221004154528-8021a29435af h1:wv66FM3rLZGPdxpYL+ApnDe2HzHcTFta3z5nsc13wI4= +golang.org/x/net v0.0.0-20221004154528-8021a29435af/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= diff --git a/internal/config/config_features.go b/internal/config/config_features.go index 18561c014..4ff5cbd51 100644 --- a/internal/config/config_features.go +++ b/internal/config/config_features.go @@ -41,6 +41,11 @@ func (c *Config) DisableExifTool() bool { return c.options.DisableExifTool } +// ExifToolEnabled checks if the use of ExifTool is possible. +func (c *Config) ExifToolEnabled() bool { + return !c.DisableExifTool() +} + // DisableTensorFlow checks if all features depending on TensorFlow should be disabled. func (c *Config) DisableTensorFlow() bool { if LowMem && !c.options.DisableTensorFlow { diff --git a/internal/config/config_features_test.go b/internal/config/config_features_test.go index d33b3d656..82642d613 100644 --- a/internal/config/config_features_test.go +++ b/internal/config/config_features_test.go @@ -59,6 +59,14 @@ func TestConfig_DisableExifTool(t *testing.T) { assert.True(t, c.DisableExifTool()) } +func TestConfig_ExifToolEnabled(t *testing.T) { + c := NewConfig(CliTestContext()) + assert.True(t, c.ExifToolEnabled()) + + c.options.ExifToolBin = "XXX" + assert.False(t, c.ExifToolEnabled()) +} + func TestConfig_DisableFaces(t *testing.T) { c := NewConfig(CliTestContext()) assert.False(t, c.DisableFaces()) diff --git a/internal/config/options_cli.go b/internal/config/options_cli.go index 3c7eb4c6c..a9555ae56 100644 --- a/internal/config/options_cli.go +++ b/internal/config/options_cli.go @@ -550,7 +550,7 @@ var Flags = CliFlags{ Flag: cli.StringFlag{ Name: "darktable-blacklist", Usage: "do not use Darktable to convert files with these `EXTENSIONS`", - Value: "dng", + Value: "", EnvVar: "PHOTOPRISM_DARKTABLE_BLACKLIST", }}, CliFlag{ @@ -578,7 +578,7 @@ var Flags = CliFlags{ Flag: cli.StringFlag{ Name: "rawtherapee-blacklist", Usage: "do not use RawTherapee to convert files with these `EXTENSIONS`", - Value: "avif,avifs", + Value: "dng", EnvVar: "PHOTOPRISM_RAWTHERAPEE_BLACKLIST", }}, CliFlag{ @@ -598,7 +598,7 @@ var Flags = CliFlags{ CliFlag{ Flag: cli.StringFlag{ Name: "heifconvert-bin", - Usage: "HEIC/HEIF image conversion `COMMAND`", + Usage: "HEIC/HEIF/AVIF image conversion `COMMAND`", Value: "heif-convert", EnvVar: "PHOTOPRISM_HEIFCONVERT_BIN", }}, diff --git a/internal/photoprism/convert.go b/internal/photoprism/convert.go index d60c105a0..dd99f74d1 100644 --- a/internal/photoprism/convert.go +++ b/internal/photoprism/convert.go @@ -99,7 +99,7 @@ func (c *Convert) Start(path string, force bool) (err error) { f, err := NewMediaFile(fileName) - if err != nil || f.Empty() || !(f.IsRaw() || f.IsHEIF() || f.IsAVIF() || f.IsImageOther() || f.IsVideo()) { + if err != nil || f.Empty() || !(f.IsRaw() || f.IsHEIC() || f.IsAVIF() || f.IsImageOther() || f.IsVideo()) { return nil } diff --git a/internal/photoprism/convert_jpeg.go b/internal/photoprism/convert_jpeg.go index bf949dfe0..8b89f53e0 100644 --- a/internal/photoprism/convert_jpeg.go +++ b/internal/photoprism/convert_jpeg.go @@ -4,14 +4,16 @@ import ( "bytes" "errors" "fmt" + "os" "os/exec" "path/filepath" "strconv" "time" + "github.com/gabriel-vasile/mimetype" + "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/thumb" - "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" ) @@ -32,6 +34,8 @@ func (c *Convert) ToJpeg(f *MediaFile, force bool) (*MediaFile, error) { return f, nil } + var err error + jpegName := fs.ImageJPEG.FindFirst(f.FileName(), []string{c.conf.SidecarPath(), fs.HiddenPath}, c.conf.OriginalsPath(), false) mediaFile, err := NewMediaFile(jpegName) @@ -56,7 +60,7 @@ func (c *Convert) ToJpeg(f *MediaFile, force bool) (*MediaFile, error) { } fileName := f.RelName(c.conf.OriginalsPath()) - xmpName := fs.XmpFile.Find(f.FileName(), false) + xmpName := fs.SidecarXMP.Find(f.FileName(), false) event.Publish("index.converting", event.Data{ "fileType": f.FileType(), @@ -81,10 +85,12 @@ func (c *Convert) ToJpeg(f *MediaFile, force bool) (*MediaFile, error) { return NewMediaFile(jpegName) } - cmd, useMutex, err := c.JpegConvertCommand(f, jpegName, xmpName) + cmds, useMutex, err := c.JpegConvertCommands(f, jpegName, xmpName) if err != nil { return nil, err + } else if len(cmds) == 0 { + return nil, fmt.Errorf("file type %s not supported", f.FileType()) } if useMutex { @@ -98,47 +104,78 @@ func (c *Convert) ToJpeg(f *MediaFile, force bool) (*MediaFile, error) { return NewMediaFile(jpegName) } - // 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())} + for _, cmd := range cmds { + // 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())} - log.Infof("convert: converting %s to %s (%s)", clean.Log(filepath.Base(fileName)), clean.Log(filepath.Base(jpegName)), filepath.Base(cmd.Path)) + log.Infof("convert: converting %s to %s (%s)", clean.Log(filepath.Base(fileName)), clean.Log(filepath.Base(jpegName)), filepath.Base(cmd.Path)) - // Log exact command for debugging in trace mode. - log.Trace(cmd.String()) + // Log exact command for debugging in trace mode. + log.Trace(cmd.String()) - // Run convert command. - if err := cmd.Run(); err != nil { - if stderr.String() != "" { - return nil, errors.New(stderr.String()) + // Run convert command. + if err = cmd.Run(); err != nil { + if stderr.String() != "" { + err = errors.New(stderr.String()) + } + + log.Tracef("convert: %s (%s)", err, filepath.Base(cmd.Path)) + continue + } else if fs.FileExistsNotEmpty(jpegName) { + log.Infof("convert: %s created in %s (%s)", clean.Log(filepath.Base(jpegName)), time.Since(start), filepath.Base(cmd.Path)) + break + } else if res := out.Bytes(); len(res) < 512 || !mimetype.Detect(res).Is(fs.MimeTypeJpeg) { + continue + } else if err = os.WriteFile(jpegName, res, os.ModePerm); err != nil { + log.Tracef("convert: %s (%s)", err, filepath.Base(cmd.Path)) + continue } else { - return nil, err + break } } - log.Infof("convert: %s created in %s (%s)", clean.Log(filepath.Base(jpegName)), time.Since(start), filepath.Base(cmd.Path)) + // Ok? + if err != nil { + return nil, err + } return NewMediaFile(jpegName) } -// JpegConvertCommand returns the command for converting files to JPEG, depending on the format. -func (c *Convert) JpegConvertCommand(f *MediaFile, jpegName string, xmpName string) (result *exec.Cmd, useMutex bool, err error) { +// JpegConvertCommands returns the command for converting files to JPEG, depending on the format. +func (c *Convert) JpegConvertCommands(f *MediaFile, jpegName string, xmpName string) (result []*exec.Cmd, useMutex bool, err error) { + result = make([]*exec.Cmd, 0, 2) + if f == nil { return result, useMutex, fmt.Errorf("file is nil - possible bug") } + // Find conversion command depending on the file type and runtime environment. fileExt := f.Extension() maxSize := strconv.Itoa(c.conf.JpegSize()) - // Select conversion command depending on the file type and runtime environment. - if (f.IsRaw() || f.IsHEIF() || f.IsAVIF()) && c.conf.SipsEnabled() && c.sipsBlacklist.Ok(fileExt) { - result = exec.Command(c.conf.SipsBin(), "-Z", maxSize, "-s", "format", "jpeg", "--out", jpegName, f.FileName()) - } else if f.IsRaw() && c.conf.RawEnabled() || f.IsAVIF() { - if c.conf.DarktableEnabled() && c.darktableBlacklist.Ok(fileExt) { + // Apple Scriptable image processing system: https://ss64.com/osx/sips.html + if (f.IsRaw() || f.IsHEIC() || f.IsAVIF()) && c.conf.SipsEnabled() && c.sipsBlacklist.Allow(fileExt) { + result = append(result, exec.Command(c.conf.SipsBin(), "-Z", maxSize, "-s", "format", "jpeg", "--out", jpegName, f.FileName())) + } + // 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)) + } + + // Video thumbnails can be created with FFmpeg. + if f.IsVideo() && c.conf.FFmpegEnabled() { + result = append(result, exec.Command(c.conf.FFmpegBin(), "-y", "-i", f.FileName(), "-ss", "00:00:00.001", "-vframes", "1", jpegName)) + } + + // RAW files may be concerted with Darktable and Rawtherapee. + if f.IsRaw() && c.conf.RawEnabled() { + if c.conf.DarktableEnabled() && c.darktableBlacklist.Allow(fileExt) { var args []string // Set RAW, XMP, and JPEG filenames. @@ -168,28 +205,37 @@ func (c *Convert) JpegConvertCommand(f *MediaFile, jpegName string, xmpName stri args = append(args, "--cachedir", dir) } - result = exec.Command(c.conf.DarktableBin(), args...) - } else if c.conf.RawtherapeeEnabled() && c.rawtherapeeBlacklist.Ok(fileExt) { + result = append(result, exec.Command(c.conf.DarktableBin(), args...)) + } + + if c.conf.RawtherapeeEnabled() && c.rawtherapeeBlacklist.Allow(fileExt) { jpegQuality := fmt.Sprintf("-j%d", c.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 = exec.Command(c.conf.RawtherapeeBin(), args...) - } else { - return nil, useMutex, fmt.Errorf("no suitable converter found") + result = append(result, exec.Command(c.conf.RawtherapeeBin(), args...)) } - } else if f.IsVideo() && c.conf.FFmpegEnabled() { - result = exec.Command(c.conf.FFmpegBin(), "-y", "-i", f.FileName(), "-ss", "00:00:00.001", "-vframes", "1", jpegName) - } else if f.IsHEIF() && c.conf.HeifConvertEnabled() { - result = exec.Command(c.conf.HeifConvertBin(), "-q", c.conf.JpegQuality().String(), f.FileName(), jpegName) - } else { - return nil, useMutex, fmt.Errorf("file type %s not supported", f.FileType()) + } + + // Extract preview image from DNG files. + if f.IsDNG() && c.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())) + } + + // No suitable converter found? + if len(result) == 0 { + return result, useMutex, fmt.Errorf("file type %s not supported", f.FileType()) } // Log convert command in trace mode only as it exposes server internals. - if result != nil { - log.Tracef("convert: %s", result.String()) + for i, cmd := range result { + if i == 0 { + log.Tracef("convert: %s", cmd.String()) + } else { + log.Tracef("convert: %s (alternative)", cmd.String()) + } } return result, useMutex, nil diff --git a/internal/photoprism/index_mediafile.go b/internal/photoprism/index_mediafile.go index f3d359b18..70ff38523 100644 --- a/internal/photoprism/index_mediafile.go +++ b/internal/photoprism/index_mediafile.go @@ -245,7 +245,7 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot photo.PhotoStack = entity.IsStackable } - if yamlName := fs.YamlFile.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), stripSequence); yamlName != "" { + if yamlName := fs.SidecarYAML.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), stripSequence); yamlName != "" { if err := photo.LoadFromYaml(yamlName); err != nil { log.Errorf("index: %s in %s (restore from yaml)", err.Error(), logName) } else { @@ -431,7 +431,7 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot log.Warn(err.Error()) file.FileError = err.Error() } - case m.IsRaw(), m.IsHEIF(), m.IsImageOther(): + case m.IsRaw(), m.IsDNG(), m.IsHEIC(), m.IsAVIF(), m.IsImageOther(): if metaData := m.MetaData(); metaData.Error == nil { // Update basic metadata. photo.SetTitle(metaData.Title, entity.SrcMeta) diff --git a/internal/photoprism/index_mediafile_test.go b/internal/photoprism/index_mediafile_test.go index 836c2d10b..aeead8e45 100644 --- a/internal/photoprism/index_mediafile_test.go +++ b/internal/photoprism/index_mediafile_test.go @@ -3,12 +3,11 @@ package photoprism import ( "testing" - "github.com/photoprism/photoprism/internal/entity" - "github.com/stretchr/testify/assert" "github.com/photoprism/photoprism/internal/classify" "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/face" "github.com/photoprism/photoprism/internal/nsfw" ) diff --git a/internal/photoprism/mediafile.go b/internal/photoprism/mediafile.go index 7b35e7ca9..2dac6df3f 100644 --- a/internal/photoprism/mediafile.go +++ b/internal/photoprism/mediafile.go @@ -352,7 +352,7 @@ func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err e matches = append(matches, name) } - isHEIF := false + isHEIC := false for _, fileName := range matches { f, fileErr := NewMediaFile(fileName) @@ -371,14 +371,16 @@ func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err e result.Main = f } else if f.IsRaw() { result.Main = f + } else if f.IsDNG() { + result.Main = f } else if f.IsAVIF() { result.Main = f - } else if f.IsHEIF() { - isHEIF = true + } else if f.IsHEIC() { + isHEIC = true result.Main = f } else if f.IsImageOther() { result.Main = f - } else if f.IsVideo() && !isHEIF { + } else if f.IsVideo() && !isHEIC { result.Main = f } else if result.Main != nil && f.IsJpeg() { if result.Main.IsJpeg() && len(result.Main.FileName()) > len(f.FileName()) { @@ -709,7 +711,7 @@ func (m *MediaFile) Extension() string { // IsJpeg return true if this media file is a JPEG image. func (m *MediaFile) IsJpeg() bool { // Don't import/use existing thumbnail files (we create our own) - if m.Extension() == ".thm" { + if m.Extension() == fs.ExtTHM { return false } @@ -731,9 +733,14 @@ func (m *MediaFile) IsTiff() bool { return m.HasFileType(fs.ImageTIFF) && m.MimeType() == fs.MimeTypeTiff } -// IsHEIF returns true if this is a High Efficiency Image File Format image. -func (m *MediaFile) IsHEIF() bool { - return m.MimeType() == fs.MimeTypeHEIF +// IsDNG returns true if this is a Adobe Digital Negative image. +func (m *MediaFile) IsDNG() bool { + return m.MimeType() == fs.MimeTypeDNG +} + +// IsHEIC returns true if this is a High Efficiency Image File Format image. +func (m *MediaFile) IsHEIC() bool { + return m.MimeType() == fs.MimeTypeHEIC } // IsAVIF returns true if this is an AV1 Image File Format image. @@ -768,7 +775,7 @@ func (m *MediaFile) IsAnimated() bool { // IsJson return true if this media file is a json sidecar file. func (m *MediaFile) IsJson() bool { - return m.HasFileType(fs.JsonFile) + return m.HasFileType(fs.SidecarJSON) } // FileType returns the file type (jpg, gif, tiff,...). @@ -780,12 +787,14 @@ func (m *MediaFile) FileType() fs.Type { return fs.ImagePNG case m.IsGif(): return fs.ImageGIF - case m.IsAVIF(): - return fs.ImageAVIF - case m.IsHEIF(): - return fs.ImageHEIF case m.IsBitmap(): return fs.ImageBMP + case m.IsDNG(): + return fs.ImageDNG + case m.IsAVIF(): + return fs.ImageAVIF + case m.IsHEIC(): + return fs.ImageHEIF default: return fs.FileType(m.fileName) } @@ -807,12 +816,12 @@ func (m *MediaFile) HasFileType(fileType fs.Type) bool { // IsRaw returns true if this is a RAW file. func (m *MediaFile) IsRaw() bool { - return m.HasFileType(fs.RawImage) + return m.HasFileType(fs.ImageRaw) || m.IsDNG() } // IsXMP returns true if this is a XMP sidecar file. func (m *MediaFile) IsXMP() bool { - return m.FileType() == fs.XmpFile + return m.FileType() == fs.SidecarXMP } // InOriginals checks if the file is stored in the 'originals' folder. @@ -852,12 +861,12 @@ func (m *MediaFile) IsImageNative() bool { // IsImage checks if the file is an image func (m *MediaFile) IsImage() bool { - return m.IsImageNative() || m.IsRaw() || m.IsAVIF() || m.IsHEIF() + return m.IsImageNative() || m.IsRaw() || m.IsDNG() || m.IsAVIF() || m.IsHEIC() } // IsLive checks if the file is a live photo. func (m *MediaFile) IsLive() bool { - if m.IsHEIF() { + if m.IsHEIC() { return fs.VideoMOV.FindFirst(m.FileName(), []string{}, Config().OriginalsPath(), false) != "" } @@ -870,12 +879,12 @@ func (m *MediaFile) IsLive() bool { // ExifSupported returns true if parsing exif metadata is supported for the media file type. func (m *MediaFile) ExifSupported() bool { - return m.IsJpeg() || m.IsRaw() || m.IsHEIF() || m.IsPng() || m.IsTiff() + return m.IsJpeg() || m.IsRaw() || m.IsHEIC() || m.IsPng() || m.IsTiff() } // IsMedia returns true if this is a media file (photo or video, not sidecar or other). func (m *MediaFile) IsMedia() bool { - return m.IsJpeg() || m.IsVideo() || m.IsRaw() || m.IsAVIF() || m.IsHEIF() || m.IsImageOther() + return m.IsImageNative() || m.IsVideo() || m.IsRaw() || m.IsDNG() || m.IsAVIF() || m.IsHEIC() } // Jpeg returns the JPEG version of the media file (if exists). diff --git a/internal/photoprism/mediafile_meta.go b/internal/photoprism/mediafile_meta.go index 62a2db515..5e77125f7 100644 --- a/internal/photoprism/mediafile_meta.go +++ b/internal/photoprism/mediafile_meta.go @@ -6,7 +6,6 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/meta" - "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" ) @@ -17,7 +16,7 @@ func (m *MediaFile) HasSidecarJson() bool { return true } - return fs.JsonFile.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false) != "" + return fs.SidecarJSON.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false) != "" } // SidecarJsonName returns the corresponding JSON sidecar file name as used by Google Photos (and potentially other apps). @@ -84,7 +83,7 @@ func (m *MediaFile) MetaData() (result meta.Data) { // Parse regular JSON sidecar files ("img_1234.json") if !m.IsSidecar() { - if jsonFiles := fs.JsonFile.FindAll(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false); len(jsonFiles) == 0 { + if jsonFiles := fs.SidecarJSON.FindAll(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false); len(jsonFiles) == 0 { log.Tracef("metadata: found no additional sidecar file for %s", clean.Log(filepath.Base(m.FileName()))) } else { for _, jsonFile := range jsonFiles { diff --git a/internal/photoprism/mediafile_test.go b/internal/photoprism/mediafile_test.go index 50e996e56..2c7a90a41 100644 --- a/internal/photoprism/mediafile_test.go +++ b/internal/photoprism/mediafile_test.go @@ -870,8 +870,10 @@ func TestMediaFile_MimeType(t *testing.T) { if err != nil { t.Fatal(err) } - assert.Equal(t, "image/tiff", mediaFile.MimeType()) + assert.Equal(t, "image/dng", mediaFile.MimeType()) + assert.True(t, mediaFile.IsDNG()) + assert.True(t, mediaFile.IsRaw()) }) t.Run("iphone_7.xmp", func(t *testing.T) { @@ -879,7 +881,7 @@ func TestMediaFile_MimeType(t *testing.T) { if err != nil { t.Fatal(err) } - assert.Equal(t, "", mediaFile.MimeType()) + assert.Equal(t, "text/plain", mediaFile.MimeType()) }) t.Run("iphone_7.json", func(t *testing.T) { @@ -887,14 +889,14 @@ func TestMediaFile_MimeType(t *testing.T) { if err != nil { t.Fatal(err) } - assert.Equal(t, "", mediaFile.MimeType()) + assert.Equal(t, "application/json", mediaFile.MimeType()) }) t.Run("fox.profile0.8bpc.yuv420.avif", func(t *testing.T) { mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/fox.profile0.8bpc.yuv420.avif") if err != nil { t.Fatal(err) } - assert.Equal(t, "image/avif", mediaFile.MimeType()) + assert.Equal(t, fs.MimeTypeAVIF, mediaFile.MimeType()) assert.True(t, mediaFile.IsAVIF()) }) t.Run("iphone_7.heic", func(t *testing.T) { @@ -902,15 +904,15 @@ func TestMediaFile_MimeType(t *testing.T) { if err != nil { t.Fatal(err) } - assert.Equal(t, "image/heif", mediaFile.MimeType()) - assert.True(t, mediaFile.IsHEIF()) + assert.Equal(t, fs.MimeTypeHEIC, mediaFile.MimeType()) + assert.True(t, mediaFile.IsHEIC()) }) t.Run("IMG_4120.AAE", func(t *testing.T) { mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/IMG_4120.AAE") if err != nil { t.Fatal(err) } - assert.Equal(t, "", mediaFile.MimeType()) + assert.Equal(t, fs.MimeTypeXML, mediaFile.MimeType()) }) t.Run("earth.mov", func(t *testing.T) { @@ -1130,28 +1132,28 @@ func TestMediaFile_IsHEIF(t *testing.T) { if err != nil { t.Fatal(err) } - assert.Equal(t, false, mediaFile.IsHEIF()) + assert.Equal(t, false, mediaFile.IsHEIC()) }) t.Run("iphone_7.heic", func(t *testing.T) { mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/iphone_7.heic") if err != nil { t.Fatal(err) } - assert.Equal(t, true, mediaFile.IsHEIF()) + assert.Equal(t, true, mediaFile.IsHEIC()) }) t.Run("canon_eos_6d.dng", func(t *testing.T) { mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/canon_eos_6d.dng") if err != nil { t.Fatal(err) } - assert.Equal(t, false, mediaFile.IsHEIF()) + assert.Equal(t, false, mediaFile.IsHEIC()) }) t.Run("elephants.jpg", func(t *testing.T) { mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg") if err != nil { t.Fatal(err) } - assert.Equal(t, false, mediaFile.IsHEIF()) + assert.Equal(t, false, mediaFile.IsHEIC()) }) } @@ -1220,8 +1222,8 @@ func TestMediaFile_IsTiff(t *testing.T) { if err != nil { t.Fatal(err) } - assert.Equal(t, fs.JsonFile, mediaFile.FileType()) - assert.Equal(t, "", mediaFile.MimeType()) + assert.Equal(t, fs.SidecarJSON, mediaFile.FileType()) + assert.Equal(t, fs.MimeTypeJSON, mediaFile.MimeType()) assert.Equal(t, false, mediaFile.IsTiff()) }) t.Run("purple.tiff", func(t *testing.T) { diff --git a/internal/query/account_uploads.go b/internal/query/account_uploads.go index c3395ef26..e29a387b8 100644 --- a/internal/query/account_uploads.go +++ b/internal/query/account_uploads.go @@ -11,7 +11,7 @@ func AccountUploads(a entity.Account, limit int) (results entity.Files, err erro Where("files.id NOT IN (SELECT file_id FROM files_sync WHERE file_id > 0 AND account_id = ?)", a.ID) if !a.SyncRaw { - s = s.Where("files.file_type <> ? OR files.file_type IS NULL", fs.RawImage) + s = s.Where("files.file_type <> ? OR files.file_type IS NULL", fs.ImageRaw) } s = s.Order("files.file_name ASC")