mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
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:
parent
a88264df82
commit
9a25a19824
9 changed files with 180 additions and 14 deletions
BIN
assets/samples/dice.jxl
Normal file
BIN
assets/samples/dice.jxl
Normal file
Binary file not shown.
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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
25
internal/thumb/jpegxl.go
Normal 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
|
||||
}
|
||||
31
internal/thumb/jpegxl_test.go
Normal file
31
internal/thumb/jpegxl_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue