Bladeren bron

Add AVIF support

Co-Authored-By: Aman Pandey <74553951+salty-ivy@users.noreply.github.com>
salty-ivy 1 jaar geleden
bovenliggende
commit
f840886b5d

+ 1 - 1
setup.py

@@ -30,7 +30,7 @@ install_requires = [
     "Pillow>=9.1.0,<11.0.0",
     "beautifulsoup4>=4.8,<4.12",
     "html5lib>=0.999,<2",
-    "Willow>=1.5,<1.6",
+    "Willow[heif]>=1.6,<1.7",
     "requests>=2.11.1,<3.0",
     "l18n>=2018.5",
     "openpyxl>=3.0.10,<4.0",

+ 5 - 1
wagtail/images/fields.py

@@ -12,7 +12,9 @@ from django.utils.translation import gettext_lazy as _
 
 def get_allowed_image_extensions():
     return getattr(
-        settings, "WAGTAILIMAGES_EXTENSIONS", ["gif", "jpg", "jpeg", "png", "webp"]
+        settings,
+        "WAGTAILIMAGES_EXTENSIONS",
+        ["avif", "gif", "jpg", "jpeg", "png", "webp"],
     )
 
 
@@ -201,5 +203,7 @@ def image_format_name_to_content_type(image_format_name):
         return "image/tiff"
     elif image_format_name == "webp":
         return "image/webp"
+    elif image_format_name == "avif":
+        return "image/avif"
     else:
         raise ValueError("Unknown image format name")

+ 15 - 2
wagtail/images/image_operations.py

@@ -386,6 +386,17 @@ class JPEGQualityOperation(FilterOperation):
         env["jpeg-quality"] = self.quality
 
 
+class AvifQualityOperation(FilterOperation):
+    def construct(self, quality):
+        self.quality = int(quality)
+
+        if self.quality > 100:
+            raise ValueError("AVIF quality must not be higher than 100")
+
+    def run(self, willow, image, env):
+        env["avif-quality"] = self.quality
+
+
 class WebPQualityOperation(FilterOperation):
     def construct(self, quality):
         self.quality = int(quality)
@@ -402,8 +413,10 @@ class FormatOperation(FilterOperation):
         self.format = format
         self.options = options
 
-        if self.format not in ["jpeg", "png", "gif", "webp"]:
-            raise ValueError("Format must be either 'jpeg', 'png', 'gif', or 'webp'")
+        if self.format not in ["jpeg", "png", "gif", "webp", "avif"]:
+            raise ValueError(
+                "Format must be either 'jpeg', 'png', 'gif', 'webp' or 'avif'"
+            )
 
     def run(self, willow, image, env):
         env["output-format"] = self.format

+ 14 - 0
wagtail/images/models.py

@@ -49,6 +49,7 @@ logger = logging.getLogger("wagtail.images")
 
 
 IMAGE_FORMAT_EXTENSIONS = {
+    "avif": ".avif",
     "jpeg": ".jpg",
     "png": ".png",
     "gif": ".gif",
@@ -917,6 +918,7 @@ class Filter:
             else:
                 # Convert bmp and webp to png by default
                 default_conversions = {
+                    "avif": "png",
                     "bmp": "png",
                     "webp": "png",
                 }
@@ -965,6 +967,18 @@ class Filter:
                     quality = getattr(settings, "WAGTAILIMAGES_WEBP_QUALITY", 85)
 
                 return willow.save_as_webp(output, quality=quality)
+            elif output_format == "avif":
+                # Allow changing of AVIF compression quality
+                if (
+                    "output-format-options" in env
+                    and "lossless" in env["output-format-options"]
+                ):
+                    return willow.save_as_avif(output, lossless=True)
+                elif "avif-quality" in env:
+                    quality = env["avif-quality"]
+                else:
+                    quality = getattr(settings, "WAGTAILIMAGES_AVIF_QUALITY", 80)
+                return willow.save_as_avif(output, quality=quality)
             elif output_format == "svg":
                 return willow.save_as_svg(output)
             raise UnknownOutputImageFormatError(

+ 2 - 2
wagtail/images/tests/test_admin_views.py

@@ -571,7 +571,7 @@ class TestImageAddView(WagtailTestUtils, TestCase):
             response,
             "form",
             "file",
-            "Not a supported image format. Supported formats: GIF, JPG, JPEG, PNG, WEBP.",
+            "Not a supported image format. Supported formats: AVIF, GIF, JPG, JPEG, PNG, WEBP.",
         )
 
     @override_settings(WAGTAILIMAGES_EXTENSIONS=["svg"])
@@ -2090,7 +2090,7 @@ class TestImageChooserUploadView(WagtailTestUtils, TestCase):
             response,
             "form",
             "file",
-            "Not a supported image format. Supported formats: GIF, JPG, JPEG, PNG, WEBP.",
+            "Not a supported image format. Supported formats: AVIF, GIF, JPG, JPEG, PNG, WEBP.",
         )
 
         # the action URL of the re-rendered form should include the select_format=true parameter

+ 108 - 3
wagtail/images/tests/test_image_operations.py

@@ -13,6 +13,7 @@ from wagtail.images.image_operations import TransformOperation
 from wagtail.images.models import Filter, Image
 from wagtail.images.tests.utils import (
     get_test_image_file,
+    get_test_image_file_avif,
     get_test_image_file_jpeg,
     get_test_image_file_tiff,
     get_test_image_file_webp,
@@ -618,6 +619,30 @@ class TestUnknownOutputImageFormat(TestCase):
 
 
 class TestFormatFilter(TestCase):
+    def test_avif(self):
+        fil = Filter(spec="width-400|format-avif")
+        image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file(),
+        )
+        out = fil.run(image, BytesIO())
+
+        self.assertEqual(out.format_name, "avif")
+
+    def test_avif_lossless(self):
+        fil = Filter(spec="width-400|format-avif-lossless")
+        image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file(),
+        )
+
+        f = BytesIO()
+        with patch("PIL.Image.Image.save") as save:
+            fil.run(image, f)
+
+        # quality=80 is default for The Willow and PIL libraries
+        save.assert_called_with(f, "AVIF", quality=-1, chroma=444)
+
     def test_jpeg(self):
         fil = Filter(spec="width-400|format-jpeg")
         image = Image.objects.create(
@@ -669,7 +694,7 @@ class TestFormatFilter(TestCase):
         with patch("PIL.Image.Image.save") as save:
             fil.run(image, f)
 
-        # quality=80 is default for Williw and PIL libs
+        # quality=80 is default for the Willow and PIL libraries
         save.assert_called_with(f, "WEBP", quality=80, lossless=True)
 
     def test_invalid(self):
@@ -681,6 +706,86 @@ class TestFormatFilter(TestCase):
         self.assertRaises(InvalidFilterSpecError, fil.run, image, BytesIO())
 
 
+class TestAvifQualityFilter(TestCase):
+    def test_default_quality(self):
+        fil = Filter(spec="width-400|format-avif")
+        image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file_avif(),
+        )
+
+        f = BytesIO()
+        with patch("PIL.Image.Image.save") as save:
+            fil.run(image, f)
+
+        save.assert_called_with(f, "AVIF", quality=80)
+
+    def test_avif_quality_filter(self):
+        fil = Filter(spec="width-400|avifquality-40|format-avif")
+        image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file_jpeg(),
+        )
+
+        f = BytesIO()
+        with patch("PIL.Image.Image.save") as save:
+            fil.run(image, f)
+
+        save.assert_called_with(f, "AVIF", quality=40)
+
+    def test_avif_quality_filter_invalid(self):
+        fil = Filter(spec="width-400|avifquality-abc|format-avif")
+        image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file_jpeg(),
+        )
+        self.assertRaises(InvalidFilterSpecError, fil.run, image, BytesIO())
+
+    def test_avif_quality_filter_no_value(self):
+        fil = Filter(spec="width-400|avifquality")
+        image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file_jpeg(),
+        )
+        self.assertRaises(InvalidFilterSpecError, fil.run, image, BytesIO())
+
+    def test_avif_quality_filter_too_big(self):
+        fil = Filter(spec="width-400|avifquality-101|format-avif")
+        image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file_jpeg(),
+        )
+        self.assertRaises(InvalidFilterSpecError, fil.run, image, BytesIO())
+
+    @override_settings(WAGTAILIMAGES_AVIF_QUALITY=50)
+    def test_avif_quality_setting(self):
+        fil = Filter(spec="width-400|format-avif")
+        image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file_jpeg(),
+        )
+
+        f = BytesIO()
+        with patch("PIL.Image.Image.save") as save:
+            fil.run(image, f)
+
+        save.assert_called_with(f, "AVIF", quality=50)
+
+    @override_settings(WAGTAILIMAGES_AVIF_QUALITY=50)
+    def test_avif_quality_filter_overrides_setting(self):
+        fil = Filter(spec="width-400|avifquality-40|format-avif")
+        image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file_jpeg(),
+        )
+
+        f = BytesIO()
+        with patch("PIL.Image.Image.save") as save:
+            fil.run(image, f)
+
+        save.assert_called_with(f, "AVIF", quality=40)
+
+
 class TestJPEGQualityFilter(TestCase):
     def test_default_quality(self):
         fil = Filter(spec="width-400")
@@ -788,7 +893,7 @@ class TestWebPQualityFilter(TestCase):
 
         save.assert_called_with(f, "WEBP", quality=40, lossless=False)
 
-    def test_webp_quality_filter_invalid(self):
+    def test_jpeg_quality_filter_invalid(self):
         fil = Filter(spec="width-400|webpquality-abc|format-webp")
         image = Image.objects.create(
             title="Test image",
@@ -827,7 +932,7 @@ class TestWebPQualityFilter(TestCase):
         save.assert_called_with(f, "WEBP", quality=50, lossless=False)
 
     @override_settings(WAGTAILIMAGES_WEBP_QUALITY=50)
-    def test_jpeg_quality_filter_overrides_setting(self):
+    def test_webp_quality_filter_overrides_setting(self):
         fil = Filter(spec="width-400|webpquality-40|format-webp")
         image = Image.objects.create(
             title="Test image",

+ 7 - 0
wagtail/images/tests/utils.py

@@ -15,6 +15,13 @@ def get_test_image_file(filename="test.png", colour="white", size=(640, 480)):
     return ImageFile(f, name=filename)
 
 
+def get_test_image_file_avif(filename="test.png", colour="white", size=(640, 480)):
+    f = BytesIO()
+    image = PIL.Image.new("RGBA", size, colour)
+    image.save(f, "AVIF")
+    return ImageFile(f, name=filename)
+
+
 def get_test_image_file_jpeg(filename="test.jpg", colour="white", size=(640, 480)):
     f = BytesIO()
     image = PIL.Image.new("RGB", size, colour)

+ 1 - 0
wagtail/images/wagtail_hooks.py

@@ -126,6 +126,7 @@ def register_image_operations():
         ("scale", image_operations.ScaleOperation),
         ("jpegquality", image_operations.JPEGQualityOperation),
         ("webpquality", image_operations.WebPQualityOperation),
+        ("avifquality", image_operations.AvifQualityOperation),
         ("format", image_operations.FormatOperation),
         ("bgcolor", image_operations.BackgroundColorOperation),
     ]