diff --git a/assets/samples/dice.jxl b/assets/samples/dice.jxl new file mode 100644 index 000000000..d1a5a7d0d Binary files /dev/null and b/assets/samples/dice.jxl differ diff --git a/internal/config/config_media.go b/internal/config/config_media.go index 01534350e..f3491426f 100644 --- a/internal/config/config_media.go +++ b/internal/config/config_media.go @@ -1,6 +1,7 @@ package config import ( + "github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/pkg/media" ) @@ -34,7 +35,8 @@ func (c *Config) ImageMagickEnabled() bool { return !c.DisableImageMagick() } -// JpegXLDecoderBin returns the JPEG XL decoder executable file name. +// JpegXLDecoderBin returns the external JPEG XL decoder executable file name, +// which is used as a fallback when libvips lacks native JPEG XL support. func (c *Config) JpegXLDecoderBin() string { return FindBin("", "djxl") } @@ -44,17 +46,32 @@ func (c *Config) JpegXLEnabled() bool { return !c.DisableJpegXL() } -// DisableJpegXL checks if JPEG XL file format support is disabled. +// DisableJpegXL checks if JPEG XL file format support is disabled. It stays enabled +// as long as the external "djxl" decoder is available or libvips can decode JPEG XL +// natively, so the format only turns off when neither option exists. func (c *Config) DisableJpegXL() bool { - if c.options.DisableJpegXL { - return true - } else if c.JpegXLDecoderBin() == "" { + if jpegXLDisabled(c.options.DisableJpegXL, c.JpegXLDecoderBin() != "", thumb.JpegXLSupported) { c.options.DisableJpegXL = true } return c.options.DisableJpegXL } +// jpegXLDisabled reports whether JPEG XL support is unavailable, given the explicit +// disable option and external decoder availability. nativeSupported is a thunk so the +// libvips capability probe runs only when no external decoder is present, keeping +// config introspection on standard installs (which ship "djxl") free of a libvips start. +func jpegXLDisabled(explicit, decoderAvailable bool, nativeSupported func() bool) bool { + switch { + case explicit: + return true + case decoderAvailable: + return false + default: + return !nativeSupported() + } +} + // 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 { diff --git a/internal/config/config_media_test.go b/internal/config/config_media_test.go index cd2b9a2b5..3a5140bf4 100644 --- a/internal/config/config_media_test.go +++ b/internal/config/config_media_test.go @@ -114,6 +114,30 @@ func TestConfig_DisableJpegXL(t *testing.T) { c.options.DisableJpegXL = false } +func TestJpegXLDisabled(t *testing.T) { + yes := func() bool { return true } + no := func() bool { return false } + t.Run("ExplicitlyDisabled", func(t *testing.T) { + assert.True(t, jpegXLDisabled(true, true, yes)) + }) + t.Run("DecoderAvailable", func(t *testing.T) { + assert.False(t, jpegXLDisabled(false, true, no)) + }) + t.Run("NativeOnly", func(t *testing.T) { + assert.False(t, jpegXLDisabled(false, false, yes)) + }) + t.Run("NeitherAvailable", func(t *testing.T) { + assert.True(t, jpegXLDisabled(false, false, no)) + }) + t.Run("DecoderShortCircuitsProbe", func(t *testing.T) { + // The libvips probe must not run when an external decoder is available, so + // config introspection on standard installs does not start libvips. + probed := false + assert.False(t, jpegXLDisabled(false, true, func() bool { probed = true; return false })) + assert.False(t, probed) + }) +} + func TestConfig_Import(t *testing.T) { c := NewConfig(CliTestContext()) diff --git a/internal/photoprism/convert_image.go b/internal/photoprism/convert_image.go index 3d22daeaf..4970a5bc6 100644 --- a/internal/photoprism/convert_image.go +++ b/internal/photoprism/convert_image.go @@ -92,8 +92,9 @@ 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. - if f.IsImageOther() { + // 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()) { 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. @@ -110,10 +111,10 @@ func (w *Convert) ToImage(f *MediaFile, force bool) (result *MediaFile, err erro if err == nil { log.Infof("convert: %s created in %s (%s)", clean.Log(filepath.Base(imageName)), time.Since(start), f.FileType()) return NewMediaFile(imageName) - } else if !f.IsTiff() && !f.IsWebp() && !f.IsHeic() && !f.IsAvif() { + } else if !f.IsTiff() && !f.IsWebp() && !f.IsHeic() && !f.IsAvif() && !f.IsJpegXL() { // See https://github.com/photoprism/photoprism/issues/1612 - // for TIFF file format compatibility. HEIC/HEIF and AVIF keep the - // external conversion fallback until we can rely on native libvips + // for TIFF file format compatibility. HEIC/HEIF, AVIF, and JPEG XL keep + // the external conversion fallback until we can rely on native libvips // support in every supported runtime. return nil, disk.AsInsufficientStorage(err) } diff --git a/internal/photoprism/convert_image_jpeg.go b/internal/photoprism/convert_image_jpeg.go index d0b172b97..f33be1a5c 100644 --- a/internal/photoprism/convert_image_jpeg.go +++ b/internal/photoprism/convert_image_jpeg.go @@ -125,8 +125,8 @@ func (w *Convert) JpegConvertCmds(f *MediaFile, jpegName string, xmpName string) ) } - // Use "djxl" to convert JPEG XL images if installed and enabled. - if f.IsJpegXL() && w.conf.JpegXLEnabled() { + // Use "djxl" to convert JPEG XL images as a fallback when libvips lacks native support. + if f.IsJpegXL() && w.conf.JpegXLEnabled() && w.conf.JpegXLDecoderBin() != "" { result = append(result, NewConvertCmd( // #nosec G204 -- arguments are built from validated config and file paths. exec.Command(w.conf.JpegXLDecoderBin(), f.FileName(), jpegName)), diff --git a/internal/photoprism/convert_image_png.go b/internal/photoprism/convert_image_png.go index ac2558dfa..81213613c 100644 --- a/internal/photoprism/convert_image_png.go +++ b/internal/photoprism/convert_image_png.go @@ -47,8 +47,8 @@ func (w *Convert) PngConvertCmds(f *MediaFile, pngName string) (result ConvertCm ) } - // Use "djxl" to convert JPEG XL images if installed and enabled. - if f.IsJpegXL() && w.conf.JpegXLEnabled() { + // Use "djxl" to convert JPEG XL images as a fallback when libvips lacks native support. + if f.IsJpegXL() && w.conf.JpegXLEnabled() && w.conf.JpegXLDecoderBin() != "" { result = append(result, NewConvertCmd( // #nosec G204 -- arguments are built from validated config and file paths. exec.Command(w.conf.JpegXLDecoderBin(), f.FileName(), pngName)), diff --git a/internal/photoprism/convert_image_test.go b/internal/photoprism/convert_image_test.go index 1a762d039..62327611d 100644 --- a/internal/photoprism/convert_image_test.go +++ b/internal/photoprism/convert_image_test.go @@ -172,6 +172,30 @@ func TestConvert_ToImage(t *testing.T) { _ = imageFile.Remove() }) + t.Run("JpegXL", func(t *testing.T) { + if !cnf.JpegXLEnabled() { + t.Skip("JPEG XL support requires libvips or djxl") + } + + jxlFile := filepath.Join(samplesPath, "dice.jxl") + + mediaFile, err := NewMediaFile(jxlFile) + if err != nil { + t.Fatal(err) + } + + imageFile, err := convert.ToImage(mediaFile, false) + if err != nil { + t.Fatal(err) + } + + assert.True(t, fs.FileExists(imageFile.FileName())) + assert.Equal(t, fs.ImageJpeg, imageFile.FileType()) + assert.Greater(t, imageFile.Width(), 0) + assert.Greater(t, imageFile.Height(), 0) + + _ = imageFile.Remove() + }) t.Run("Layered16BitTiff", func(t *testing.T) { tiffFile := filepath.Join(samplesPath, "layered-16bit-small.tif") @@ -386,6 +410,50 @@ func TestConvert_JpegConvertCmds_HeifFallback(t *testing.T) { } } +// TestConvert_JpegConvertCmds_JpegXLFallback verifies that the external "djxl" +// fallback command is emitted for JPEG XL inputs when the decoder is available, +// so runtimes whose libvips lacks JPEG XL support can still convert these files. +func TestConvert_JpegConvertCmds_JpegXLFallback(t *testing.T) { + cnf := config.TestConfig() + convert := NewConvert(cnf) + + if cnf.JpegXLDecoderBin() == "" { + t.Skip("djxl must be available for the JPEG XL fallback path") + } + + srcFile := filepath.Join(cnf.SamplesPath(), "dice.jxl") + dstFile := filepath.Join(t.TempDir(), "dice.jxl.jpg") + + mediaFile, err := NewMediaFile(srcFile) + if err != nil { + t.Fatal(err) + } + + if !mediaFile.IsJpegXL() { + t.Fatalf("%s not recognized as JPEG XL", srcFile) + } + + cmds, useMutex, err := convert.JpegConvertCmds(mediaFile, dstFile, "") + if err != nil { + t.Fatal(err) + } + + assert.False(t, useMutex) + assert.NotEmpty(t, cmds) + + djxlBin := filepath.Base(cnf.JpegXLDecoderBin()) + found := false + for _, cmd := range cmds { + s := cmd.String() + if strings.Contains(s, djxlBin) && strings.Contains(s, srcFile) && strings.Contains(s, dstFile) { + found = true + break + } + } + + assert.True(t, found, "expected a djxl fallback command for %s", srcFile) +} + func TestConvert_PngConvertCmds(t *testing.T) { cnf := config.TestConfig() convert := NewConvert(cnf) diff --git a/internal/thumb/jpegxl.go b/internal/thumb/jpegxl.go new file mode 100644 index 000000000..16a1207c7 --- /dev/null +++ b/internal/thumb/jpegxl.go @@ -0,0 +1,25 @@ +package thumb + +import ( + "sync" + + "github.com/davidbyttow/govips/v2/vips" +) + +var ( + jpegXLOnce sync.Once + jpegXLSupported bool +) + +// JpegXLSupported reports whether the libvips runtime can decode JPEG XL images. +// The result is probed once and cached, since libvips capabilities are fixed for the +// lifetime of the process. Support depends on the libvips build including the JXL +// module (libjxl); when it is missing, callers fall back to the external "djxl" decoder. +func JpegXLSupported() bool { + jpegXLOnce.Do(func() { + VipsInit() + jpegXLSupported = vips.IsTypeSupported(vips.ImageTypeJXL) + }) + + return jpegXLSupported +} diff --git a/internal/thumb/jpegxl_test.go b/internal/thumb/jpegxl_test.go new file mode 100644 index 000000000..8799670e9 --- /dev/null +++ b/internal/thumb/jpegxl_test.go @@ -0,0 +1,31 @@ +package thumb + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJpegXLSupported(t *testing.T) { + // The development and CI runtime ships libvips with the JXL module (libjxl). + assert.True(t, JpegXLSupported()) +} + +func TestJpeg_JpegXL(t *testing.T) { + if !JpegXLSupported() { + t.Skip("libvips was built without JPEG XL support") + } + + src := filepath.Join(SamplesPath, "dice.jxl") + dst := filepath.Join(t.TempDir(), "dice.jxl.jpg") + + img, err := Jpeg(src, dst, OrientationNormal) + require.NoError(t, err) + require.NotNil(t, img) + + assert.FileExists(t, dst) + assert.Greater(t, img.Bounds().Max.X, 0) + assert.Greater(t, img.Bounds().Max.Y, 0) +}