`max` filter breaks animated gifs
See original GitHub issueIssue Summary
Uploading a gif (this one in particular):
breaks wagtail /admin/images
route.
The error is fun, this line https://github.com/emcconville/wand/blob/4fe2c6ba9cb0d4105361cea6e9e9e83116080473/wand/image.py#L1501 gets two different values, 421
and 420
, raising an error in _abs
function.
I am not sure whether this comes from Wagtail or Wand, and I would like some help to track this down.
Fix : There is a fix for this issue : replacing max
by fill
here helps get rid of it https://github.com/wagtail/wagtail/blob/main/wagtail/images/templates/wagtailimages/images/results_image.html#L18
Steps to Reproduce
- Add
Wand
to your wagtail project to support animated gifs - In wagtail admin, go to http://localhost:8000/admin/images/multiple/add/ and upload the gif
- Go to http://localhost:8000/admin/images/
- Enjoy 🍿
I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes, on https://github.com/wagtail/docker-wagtail-develop I added Wand
as a dependency, and the error occurs.
Traceback below if it can be of any help
Environment:
Request Method: GET
Request URL: http://localhost:8000/admin/images/
Django Version: 3.2.10
Python Version: 3.8.12
Installed Applications:
['bakerydemo.base',
'bakerydemo.blog',
'bakerydemo.breads',
'bakerydemo.locations',
'bakerydemo.search',
'wagtail.contrib.search_promotions',
'wagtail.contrib.forms',
'wagtail.contrib.redirects',
'wagtail.embeds',
'wagtail.sites',
'wagtail.users',
'wagtail.snippets',
'wagtail.documents',
'wagtail.images',
'wagtail.search',
'wagtail.admin',
'wagtail.api.v2',
'wagtail.contrib.modeladmin',
'wagtail.contrib.routable_page',
'wagtail.core',
'rest_framework',
'modelcluster',
'taggit',
'wagtailfontawesome',
'debug_toolbar',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sitemaps']
Installed Middleware:
['debug_toolbar.middleware.DebugToolbarMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'wagtail.contrib.redirects.middleware.RedirectMiddleware']
Template error:
In template /code/wagtail/wagtail/images/templates/wagtailimages/images/results_image.html, error at line 18
421 > 420
8 : This behaviour caused a confusing error on the images listing view where it
9 : would go blank if one of the images was invalid.
10 :
11 : Separating the image rendering code into this file allows us to limit Django's
12 : crash/blanking behaviour to a single image so the listing can still be used when
13 : the issue occurs.
14 : {% endcomment %}
15 :
16 : {% load wagtailimages_tags %}
17 :
18 : <div class="image"> {% image image max-165x165 class="show-transparency" alt="" %} </div>
19 :
Traceback (most recent call last):
File "/code/wagtail/wagtail/images/models.py", line 307, in get_rendition
rendition = self.renditions.get(
File "/usr/local/lib/python3.8/site-packages/django/db/models/manager.py", line 85, in manager_method
return getattr(self.get_queryset(), name)(*args, **kwargs)
File "/usr/local/lib/python3.8/site-packages/django/db/models/query.py", line 435, in get
raise self.model.DoesNotExist(
During handling of the above exception (Rendition matching query does not exist.), another exception occurred:
File "/usr/local/lib/python3.8/site-packages/django/core/handlers/exception.py", line 47, in inner
response = get_response(request)
File "/usr/local/lib/python3.8/site-packages/django/core/handlers/base.py", line 204, in _get_response
response = response.render()
File "/usr/local/lib/python3.8/site-packages/django/template/response.py", line 105, in render
self.content = self.rendered_content
File "/usr/local/lib/python3.8/site-packages/django/template/response.py", line 83, in rendered_content
return template.render(context, self._request)
File "/usr/local/lib/python3.8/site-packages/django/template/backends/django.py", line 61, in render
return self.template.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 170, in render
return self._render(context)
File "/usr/local/lib/python3.8/site-packages/django/test/utils.py", line 100, in instrumented_test_render
return self.nodelist.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 938, in render
bit = node.render_annotated(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 905, in render_annotated
return self.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/loader_tags.py", line 150, in render
return compiled_parent._render(context)
File "/usr/local/lib/python3.8/site-packages/django/test/utils.py", line 100, in instrumented_test_render
return self.nodelist.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 938, in render
bit = node.render_annotated(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 905, in render_annotated
return self.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/loader_tags.py", line 150, in render
return compiled_parent._render(context)
File "/usr/local/lib/python3.8/site-packages/django/test/utils.py", line 100, in instrumented_test_render
return self.nodelist.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 938, in render
bit = node.render_annotated(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 905, in render_annotated
return self.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/loader_tags.py", line 150, in render
return compiled_parent._render(context)
File "/usr/local/lib/python3.8/site-packages/django/test/utils.py", line 100, in instrumented_test_render
return self.nodelist.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 938, in render
bit = node.render_annotated(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 905, in render_annotated
return self.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/loader_tags.py", line 62, in render
result = block.nodelist.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 938, in render
bit = node.render_annotated(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 905, in render_annotated
return self.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/loader_tags.py", line 62, in render
result = block.nodelist.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 938, in render
bit = node.render_annotated(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 905, in render_annotated
return self.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/loader_tags.py", line 195, in render
return template.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 172, in render
return self._render(context)
File "/usr/local/lib/python3.8/site-packages/django/test/utils.py", line 100, in instrumented_test_render
return self.nodelist.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 938, in render
bit = node.render_annotated(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 905, in render_annotated
return self.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/defaulttags.py", line 312, in render
return nodelist.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 938, in render
bit = node.render_annotated(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 905, in render_annotated
return self.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/defaulttags.py", line 211, in render
nodelist.append(node.render_annotated(context))
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 905, in render_annotated
return self.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/loader_tags.py", line 195, in render
return template.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 172, in render
return self._render(context)
File "/usr/local/lib/python3.8/site-packages/django/test/utils.py", line 100, in instrumented_test_render
return self.nodelist.render(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 938, in render
bit = node.render_annotated(context)
File "/usr/local/lib/python3.8/site-packages/django/template/base.py", line 905, in render_annotated
return self.render(context)
File "/code/wagtail/wagtail/images/templatetags/wagtailimages_tags.py", line 107, in render
rendition = get_rendition_or_not_found(image, self.filter)
File "/code/wagtail/wagtail/images/shortcuts.py", line 13, in get_rendition_or_not_found
return image.get_rendition(specs)
File "/code/wagtail/wagtail/images/models.py", line 321, in get_rendition
generated_image = filter.run(self, BytesIO())
File "/code/wagtail/wagtail/images/models.py", line 476, in run
willow = willow.crop(transform.get_rect().round())
File "/usr/local/lib/python3.8/site-packages/willow/plugins/wand.py", line 87, in crop
clone.image.crop(left=rect[0], top=rect[1], right=rect[2], bottom=rect[3])
File "/usr/local/lib/python3.8/site-packages/wand/image.py", line 1089, in wrapped
result = function(self, *args, **kwargs)
File "/usr/local/lib/python3.8/site-packages/wand/image.py", line 1098, in wrapped
result = function(self, *args, **kwargs)
File "/usr/local/lib/python3.8/site-packages/wand/image.py", line 4490, in crop
bottom = abs_(bottom, self.height)
File "/usr/local/lib/python3.8/site-packages/wand/image.py", line 4472, in abs_
raise ValueError(repr(n) + ' > ' + repr(m))
Exception Type: ValueError at /admin/images/
Exception Value: 421 > 420
Technical details
- Python version: 3.8.12
- Django version: 3.2.10
- Wagtail version: 2.15.1
Issue Analytics
- State:
- Created 2 years ago
- Reactions:1
- Comments:12 (6 by maintainers)
Top GitHub Comments
Have now released Willow 1.4.1 which fixes this - will open a new lower-priority ticket for addressing the underlying rounding error issue.
OK, this turns out to be quite hairy…
max-165x165
filter is translated to aMinMaxOperation
instanceMinMaxOperation.run
calculates the appropriate final size for the image, with both dimensions rounded to integers, and passes this toImageTransform.resize
ImageTransform.resize
reverses the calculation MinMaxOperation just did to get an X and Y scale factor, which is how ImageTransform represents scale operations internally. (Note: even though this resize operation is supposed to preserve aspect ratio, the integer rounding in the previous step means that the X and Y factors are liable to end up slightly different. I think this detail is a red herring though…)willow.crop(transform.get_rect().round())
.transform.get_rect()
calculates the crop rectangle to apply to the original image, by multiplying the target size by the scale factors. Since themax
filter doesn’t do any cropping, this should just return the original image size. However, this unnecessary division and multiplication causes floating point inaccuracies to creep in, and the result is in some cases infinitesimally larger. Taking the original 640x420 image from this issue as input, the resulting rectangle isRect(left: -0.0, top: -0.0, right: 640.0, bottom: 420.00000000000006)
Rect.round()
rounds the dimensions up - presumably to avoid zero-sized images - resulting inRect(left: 0, top: 0, right: 640, bottom: 421)