JPEG XL: Decode natively via libvips with "djxl" fallback #5693

Route JPEG XL through the native libvips path (govips jxlload) like
HEIC/AVIF, keeping the external "djxl" decoder as an automatic fallback
for runtimes whose libvips lacks JPEG XL support.

Decouple format enablement from "djxl": DisableJpegXL now stays enabled
when either the decoder is present or libvips can decode JPEG XL natively.
The libvips capability probe (thumb.JpegXLSupported) is cached and runs
only when no decoder is present, so config introspection on standard
installs (which ship "djxl") does not start libvips.

Add a shared assets/samples/dice.jxl fixture and focused tests for the
native conversion path, the djxl fallback command, and the capability
decision table.
This commit is contained in:
Michael Mayer 2026-06-25 14:31:10 +00:00
parent a88264df82
commit 9a25a19824
9 changed files with 180 additions and 14 deletions

BIN
assets/samples/dice.jxl Normal file

Binary file not shown.

View file

@ -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 {

View file

@ -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())

View file

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

View file

@ -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)),

View file

@ -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)),

View file

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

25
internal/thumb/jpegxl.go Normal file
View file

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

View file

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