Images: Prefer native HEIF and AVIF conversion via libvips #5509

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2026-04-01 15:18:21 +02:00
parent 812faccae6
commit 568432df88
6 changed files with 110 additions and 9 deletions
+5 -3
View File
@@ -87,7 +87,7 @@ func (w *Convert) ToImage(f *MediaFile, force bool) (result *MediaFile, err erro
start := time.Now()
// PNG, GIF, BMP, TIFF, and WebP can be handled natively.
// PNG, GIF, BMP, TIFF, HEIC/HEIF, AVIF, and WebP can be handled natively.
if f.IsImageOther() {
log.Infof("convert: converting %s to %s (%s)", clean.Log(filepath.Base(fileName)), clean.Log(filepath.Base(imageName)), f.FileType())
@@ -105,9 +105,11 @@ 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() {
} else if !f.IsTiff() && !f.IsWebp() && !f.IsHeic() && !f.IsAvif() {
// See https://github.com/photoprism/photoprism/issues/1612
// for TIFF file format compatibility.
// for TIFF file format compatibility. HEIC/HEIF and AVIF keep the
// external conversion fallback until we can rely on native libvips
// support in every supported runtime.
return nil, err
}
}
+2 -2
View File
@@ -1177,10 +1177,10 @@ func (m *MediaFile) SkipTranscoding() bool {
return !m.NeedsTranscoding()
}
// IsImageOther returns true if this is a PNG, GIF, BMP, TIFF, or WebP file.
// IsImageOther returns true if this is a PNG, GIF, BMP, TIFF, HEIC/HEIF, AVIF, or WebP file.
func (m *MediaFile) IsImageOther() bool {
switch {
case m.IsPng(), m.IsGif(), m.IsTiff(), m.IsBmp(), m.IsWebp():
case m.IsPng(), m.IsGif(), m.IsTiff(), m.IsBmp(), m.IsHeic(), m.IsAvif(), m.IsWebp():
return true
default:
return false
+56 -2
View File
@@ -19,6 +19,15 @@ func TestMediaFile_Heic(t *testing.T) {
c := config.TestConfig()
t.Run("IphoneSevenHeic", func(t *testing.T) {
prevDisableHeifConvert := conf.Options().DisableHeifConvert
prevDisableImageMagick := conf.Options().DisableImageMagick
conf.Options().DisableHeifConvert = true
conf.Options().DisableImageMagick = true
t.Cleanup(func() {
conf.Options().DisableHeifConvert = prevDisableHeifConvert
conf.Options().DisableImageMagick = prevDisableImageMagick
})
img, err := NewMediaFile(filepath.Join(conf.SamplesPath(), "iphone_7.heic"))
if err != nil {
@@ -56,8 +65,10 @@ func TestMediaFile_Heic(t *testing.T) {
assert.Equal(t, "", jpegInfo.DocumentID)
assert.Equal(t, "2018-09-10 03:16:13.023 +0000 UTC", jpegInfo.TakenAt.String())
assert.Equal(t, "2018-09-10 12:16:13.023 +0000 UTC", jpegInfo.TakenAtLocal.String())
// KNOWN ISSUE: Orientation 6 would be correct instead (or the image should already be rotated),
// see https://github.com/strukturag/libheif/issues/227#issuecomment-1532842570
// The native libvips/libheif path does not apply EXIF orientation for HEIF files
// because the HEIF spec treats it as informational only (see strukturag/libheif#227).
// This iPhone 7 file lacks an irot box and carries EXIF orientation 6, which is
// technically non-conformant. The output retains the raw sensor dimensions.
assert.Equal(t, 1, jpegInfo.Orientation)
assert.Equal(t, "iPhone 7", jpegInfo.CameraModel)
assert.Equal(t, "Apple", jpegInfo.CameraMake)
@@ -82,6 +93,15 @@ func TestMediaFile_Heic(t *testing.T) {
}
})
t.Run("IphoneFifteenProHeic", func(t *testing.T) {
prevDisableHeifConvert := c.Options().DisableHeifConvert
prevDisableImageMagick := c.Options().DisableImageMagick
c.Options().DisableHeifConvert = true
c.Options().DisableImageMagick = true
t.Cleanup(func() {
c.Options().DisableHeifConvert = prevDisableHeifConvert
c.Options().DisableImageMagick = prevDisableImageMagick
})
img, err := NewMediaFile(filepath.Join(c.SamplesPath(), "iphone_15_pro.heic"))
if err != nil {
@@ -140,4 +160,38 @@ func TestMediaFile_Heic(t *testing.T) {
t.Error(err)
}
})
t.Run("FoxProfileAvif", func(t *testing.T) {
prevDisableHeifConvert := c.Options().DisableHeifConvert
prevDisableImageMagick := c.Options().DisableImageMagick
c.Options().DisableHeifConvert = true
c.Options().DisableImageMagick = true
t.Cleanup(func() {
c.Options().DisableHeifConvert = prevDisableHeifConvert
c.Options().DisableImageMagick = prevDisableImageMagick
})
img, err := NewMediaFile(filepath.Join(c.SamplesPath(), "fox.profile0.8bpc.yuv420.avif"))
if err != nil {
t.Fatal(err)
}
convert := NewConvert(c)
jpeg, err := convert.ToImage(img, true)
if err != nil {
t.Fatal(err)
}
assert.NotNil(t, jpeg)
assert.True(t, jpeg.IsJpeg())
assert.True(t, jpeg.Exists())
assert.Greater(t, jpeg.Width(), 0)
assert.Greater(t, jpeg.Height(), 0)
if err = os.Remove(filepath.Join(c.SidecarPath(), c.SamplesPath(), "fox.profile0.8bpc.yuv420.avif.jpg")); err != nil {
t.Error(err)
}
})
}
+16
View File
@@ -1181,6 +1181,22 @@ func TestMediaFile_IsImageOther(t *testing.T) {
}
assert.False(t, mediaFile.IsImageOther())
})
t.Run("IphoneSevenHeic", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.SamplesPath() + "/iphone_7.heic")
if err != nil {
t.Fatal(err)
}
assert.True(t, mediaFile.IsImageOther())
assert.True(t, mediaFile.IsImageNative())
})
t.Run("FoxProfileAvif", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.SamplesPath() + "/fox.profile0.8bpc.yuv420.avif")
if err != nil {
t.Fatal(err)
}
assert.True(t, mediaFile.IsImageOther())
assert.True(t, mediaFile.IsImageNative())
})
t.Run("PurpleTiff", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.SamplesPath() + "/purple.tiff")
if err != nil {
+1
View File
@@ -77,6 +77,7 @@
- Cached thumbnails are written as JPEG or PNG and may be reopened through bounded helper paths for crop, preview, and AI follow-up work.
- TIFF is intentionally excluded from generic Go decoder registration in this package and related callers so future code paths cannot reach the unsafe generic TIFF dispatch by accident.
- Already-decoded `image.Image` values are resized, cropped, rotated, and saved through stdlib plus `golang.org/x/image/draw` helpers so the package no longer depends on `github.com/disintegration/imaging`.
- HEIC/HEIF and AVIF originals are converted through `vipsConvert`, which uses libvips (via libheif) to load and export the image. Because libheif always applies ISOBMFF `irot`/`imir` container transforms during decode and the HEIF spec treats EXIF orientation as informational only (see `strukturag/libheif#227`), `vipsConvert` skips explicit EXIF-based rotation entirely for images loaded through `heifload`. Older Apple HEIC files without `irot` (e.g. iPhone 7) that carry EXIF orientation are non-conformant per the spec and are not auto-rotated by this path.
### Go 1.26 JPEG Notes
+30 -2
View File
@@ -21,6 +21,19 @@ func vipsConvertImportParams() *vips.ImportParams {
// vipsConvert loads a source image with libvips, applies the explicit orientation, and exports it.
// Unlike thumbnail generation, format conversion preserves source metadata where libvips can carry it over.
//
// Orientation handling for HEIF/AVIF (see strukturag/libheif#227):
// libheif always applies ISOBMFF irot/imir transforms during decode, and the HEIF spec treats
// EXIF orientation as informational only. There is no reliable metadata signal from libheif
// that distinguishes "irot was applied" from "no irot was present," so we follow the spec:
// when the image was loaded through libheif (detected via the vips-loader field), we never
// apply the caller's EXIF orientation. This is correct for conformant files and avoids
// double-rotation for all transform types including square rotations and pure flips.
//
// Trade-off: some older Apple HEIC files (e.g. iPhone 7) carry EXIF orientation without an
// irot box, which is non-conformant per the HEIF spec. These files will not be auto-rotated
// by this path. The libheif maintainer recommends treating this as the spec-correct behavior
// rather than introducing heuristics that risk corrupting conformant files.
func vipsConvert(srcFile, dstFile string, orientation int) (_ image.Image, err error) {
VipsInit()
@@ -30,8 +43,17 @@ func vipsConvert(srcFile, dstFile string, orientation int) (_ image.Image, err e
}
defer img.Close()
if err = VipsRotate(img, orientation); err != nil {
return nil, err
// Apply orientation — but not for images loaded through libheif.
//
// libheif applies all ISOBMFF irot/imir pixel transforms during decode and the HEIF
// spec says EXIF orientation is informational only (strukturag/libheif#227). There is
// no post-load signal that reliably distinguishes "irot applied" from "no irot present"
// for every transform type (dimension-preserving rotations, flips), so we follow the
// spec and skip explicit rotation entirely for heifload images.
if orientation > OrientationNormal && !vipsLoadedViaHeif(img) {
if err = VipsRotate(img, orientation); err != nil {
return nil, err
}
}
if err = img.RemoveOrientation(); err != nil {
@@ -65,3 +87,9 @@ func vipsConvert(srcFile, dstFile string, orientation int) (_ image.Image, err e
return decoded, nil
}
// vipsLoadedViaHeif reports whether the image was decoded by the libheif loader.
func vipsLoadedViaHeif(img *vips.ImageRef) bool {
loader := img.GetString("vips-loader")
return len(loader) >= 8 && loader[:8] == "heifload"
}