mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
parent
d330f0e05c
commit
5eeb0d6291
7 changed files with 138 additions and 14 deletions
|
|
@ -11,6 +11,7 @@ import (
|
|||
type ConvertCmd struct {
|
||||
Cmd *exec.Cmd
|
||||
Orientation media.Orientation
|
||||
VerifyImage bool
|
||||
}
|
||||
|
||||
// String returns the conversion command as string e.g. for logging.
|
||||
|
|
@ -33,6 +34,13 @@ func (c *ConvertCmd) ResetOrientation() *ConvertCmd {
|
|||
return c.WithOrientation(media.ResetOrientation)
|
||||
}
|
||||
|
||||
// WithImageVerification marks the command's output for a decode check before acceptance,
|
||||
// so corrupt embedded previews are rejected and the loop tries the next command.
|
||||
func (c *ConvertCmd) WithImageVerification() *ConvertCmd {
|
||||
c.VerifyImage = true
|
||||
return c
|
||||
}
|
||||
|
||||
// NewConvertCmd returns a new file converter command with default options.
|
||||
func NewConvertCmd(cmd *exec.Cmd) *ConvertCmd {
|
||||
if cmd == nil {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@ func TestNewConvertCmd(t *testing.T) {
|
|||
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)
|
||||
})
|
||||
t.Run("WithImageVerification", func(t *testing.T) {
|
||||
result := NewConvertCmd(
|
||||
exec.Command("exiftool", "-q", "-q", "-b", "-PreviewImage", "file.cr3"),
|
||||
)
|
||||
assert.False(t, result.VerifyImage)
|
||||
assert.Same(t, result, result.WithImageVerification())
|
||||
assert.True(t, result.VerifyImage)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewConvertCmds(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -92,9 +92,10 @@ func (w *Convert) ToImage(f *MediaFile, force bool) (result *MediaFile, err erro
|
|||
|
||||
start := time.Now()
|
||||
|
||||
// PNG, GIF, BMP, TIFF, HEIC/HEIF, AVIF, and WebP can be handled natively, as can
|
||||
// JPEG XL when the libvips runtime provides JPEG XL support.
|
||||
if f.IsImageOther() || (f.IsJpegXL() && thumb.JpegXLSupported() && w.conf.JpegXLEnabled()) {
|
||||
// PNG, GIF, BMP, TIFF, HEIC/HEIF, AVIF, and WebP are handled natively. JPEG XL only when
|
||||
// "djxl" is unavailable: some libvips builds report JPEG XL support but mis-render files
|
||||
// without erroring, so the reference "djxl" decoder is preferred when present.
|
||||
if f.IsImageOther() || (f.IsJpegXL() && thumb.JpegXLSupported() && w.conf.JpegXLEnabled() && w.conf.JpegXLDecoderBin() == "") {
|
||||
log.Infof("convert: converting %s to %s (%s)", clean.Log(filepath.Base(fileName)), clean.Log(filepath.Base(imageName)), f.FileType())
|
||||
|
||||
// Create PNG or JPEG image from source file.
|
||||
|
|
@ -181,19 +182,30 @@ func (w *Convert) ToImage(f *MediaFile, force bool) (result *MediaFile, err erro
|
|||
log.Debugf("convert: %s (%s)", clean.Error(err), filepath.Base(cmd.Path))
|
||||
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
|
||||
// The command wrote the target file directly (e.g. Darktable, RawTherapee).
|
||||
} else if res := out.Bytes(); len(res) < 512 || !mimetype.Detect(res).Is(expectedMime) {
|
||||
continue
|
||||
} else if err = os.WriteFile(imageName, res, fs.ModeFile); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// Reject undecodable output (e.g. a truncated/bogus embedded RAW preview that passed
|
||||
// the MIME sniff) so the loop tries the next converter instead of indexing a file
|
||||
// whose thumbnails will fail.
|
||||
if c.VerifyImage {
|
||||
if err = thumb.Verify(imageName); err != nil {
|
||||
log.Debugf("convert: discarding undecodable %s from %s (%s)", clean.Log(filepath.Base(imageName)), filepath.Base(cmd.Path), clean.Error(err))
|
||||
if removeErr := os.Remove(imageName); removeErr != nil && !os.IsNotExist(removeErr) {
|
||||
log.Tracef("convert: %s (%s)", removeErr, filepath.Base(cmd.Path))
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("convert: %s created in %s (%s)", clean.Log(filepath.Base(imageName)), time.Since(start), filepath.Base(cmd.Path))
|
||||
fileOrientation = c.Orientation
|
||||
break
|
||||
}
|
||||
|
||||
// Ok?
|
||||
|
|
|
|||
|
|
@ -95,11 +95,11 @@ func (w *Convert) JpegConvertCmds(f *MediaFile, jpegName string, xmpName string)
|
|||
if w.conf.ExifToolEnabled() {
|
||||
result = append(result, NewConvertCmd(
|
||||
// #nosec G204 -- arguments are built from validated config and file paths.
|
||||
exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-JpgFromRaw", f.FileName())),
|
||||
exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-JpgFromRaw", f.FileName())).WithImageVerification(),
|
||||
)
|
||||
result = append(result, NewConvertCmd(
|
||||
// #nosec G204 -- arguments are built from validated config and file paths.
|
||||
exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-PreviewImage", f.FileName())),
|
||||
exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-PreviewImage", f.FileName())).WithImageVerification(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ func (w *Convert) JpegConvertCmds(f *MediaFile, jpegName string, xmpName string)
|
|||
// Example: exiftool -b -PreviewImage -w IMG_4691.DNG.jpg IMG_4691.DNG
|
||||
result = append(result, NewConvertCmd(
|
||||
// #nosec G204 -- arguments are built from validated config and file paths.
|
||||
exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-PreviewImage", f.FileName())),
|
||||
exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-PreviewImage", f.FileName())).WithImageVerification(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -165,7 +165,7 @@ func (w *Convert) JpegConvertCmds(f *MediaFile, jpegName string, xmpName string)
|
|||
if (f.IsTiff() || f.IsPsd()) && w.conf.ExifToolEnabled() {
|
||||
result = append(result, NewConvertCmd(
|
||||
// #nosec G204 -- arguments are built from validated config and file paths.
|
||||
exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-PhotoshopThumbnail", f.FileName())),
|
||||
exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-PhotoshopThumbnail", f.FileName())).WithImageVerification(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
|
|
@ -50,6 +51,38 @@ func TestConvert_ToImage(t *testing.T) {
|
|||
|
||||
_ = os.Remove(outputName)
|
||||
})
|
||||
t.Run("JpegXL", func(t *testing.T) {
|
||||
if cnf.JpegXLDecoderBin() == "" {
|
||||
t.Skip("djxl must be available for the JPEG XL conversion path")
|
||||
}
|
||||
|
||||
fileName := filepath.Join(cnf.SamplesPath(), "dice.jxl")
|
||||
outputName := filepath.Join(cnf.SidecarPath(), cnf.SamplesPath(), "dice.jxl.jpg")
|
||||
|
||||
_ = os.Remove(outputName)
|
||||
|
||||
mf, err := NewMediaFile(fileName)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.True(t, mf.IsJpegXL())
|
||||
|
||||
img, err := convert.ToImage(mf, false)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// With djxl available the native libvips path is skipped (some builds
|
||||
// mis-render JPEG XL); the djxl-produced preview must be decodable.
|
||||
assert.Equal(t, outputName, img.FileName())
|
||||
assert.True(t, img.IsPreviewImage())
|
||||
assert.NoError(t, thumb.Verify(img.FileName()))
|
||||
|
||||
_ = os.Remove(outputName)
|
||||
})
|
||||
t.Run("Raw", func(t *testing.T) {
|
||||
jpegFilename := filepath.Join(cnf.ImportPath(), "fern_green.jpg")
|
||||
|
||||
|
|
|
|||
34
internal/thumb/verify.go
Normal file
34
internal/thumb/verify.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package thumb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
)
|
||||
|
||||
// Verify reports an error if the image file cannot be decoded by the active rendering library.
|
||||
// Used to reject corrupt converter output (e.g. a truncated embedded RAW preview that passes a
|
||||
// MIME sniff but later fails libvips) so the conversion loop can try the next converter.
|
||||
func Verify(fileName string) error {
|
||||
if fileName == "" {
|
||||
return fmt.Errorf("verify: empty filename")
|
||||
}
|
||||
|
||||
// Use the loader the thumbnailer will use so the check matches GenerateThumbnails.
|
||||
if Library == LibVips {
|
||||
VipsInit()
|
||||
|
||||
img, err := vips.LoadImageFromFile(fileName, VipsImportParams())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
img.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := Open(fileName, 1)
|
||||
|
||||
return err
|
||||
}
|
||||
29
internal/thumb/verify_test.go
Normal file
29
internal/thumb/verify_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package thumb
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
t.Run("ValidJPEG", func(t *testing.T) {
|
||||
assert.NoError(t, Verify("testdata/example.jpg"))
|
||||
})
|
||||
t.Run("ValidPNG", func(t *testing.T) {
|
||||
assert.NoError(t, Verify("testdata/example.png"))
|
||||
})
|
||||
t.Run("EmptyFilename", func(t *testing.T) {
|
||||
assert.Error(t, Verify(""))
|
||||
})
|
||||
t.Run("NotAnImage", func(t *testing.T) {
|
||||
name := filepath.Join(t.TempDir(), "garbage.jpg")
|
||||
assert.NoError(t, os.WriteFile(name, []byte("this is not a valid jpeg payload, only plain text"), 0o644))
|
||||
assert.Error(t, Verify(name))
|
||||
})
|
||||
t.Run("MissingFile", func(t *testing.T) {
|
||||
assert.Error(t, Verify(filepath.Join(t.TempDir(), "does-not-exist.jpg")))
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue