Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/ref/animated_formats.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,25 @@ PNGs.
There have been issues with conversion from GIF to WEBP, so it's currently not recommended to
enable this specific conversion for animated images.

Animated GIF
============

Thumbnailing animated GIFs requires extra processing. To avoid this, you can enable the
`RGB_ALWAYS` loading strategy for the GifImagePlugin by adding this to your project:

.. code-block:: python

from PIL import GifImagePlugin
GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_ALWAYS

This setting is an optional optimization because it changes how all GIFs are loaded by
Pillow, not just animated GIFs. The `Pillow GIF docs
<https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#gif/>`_
explain how this settings works and you can decide if it's the right choice for
your project.

Remark
======

In the future, Easy Thumbnails might preserve animated images by default, and/or provide the
option to enable/disable animations for each generated thumbnail.
option to enable/disable animations for each generated thumbnail.
17 changes: 15 additions & 2 deletions easy_thumbnails/processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from functools import partial
from io import BytesIO

from PIL import Image, ImageChops, ImageFilter
from PIL import Image, ImageChops, ImageFilter, GifImagePlugin
from easy_thumbnails import utils


Expand Down Expand Up @@ -55,7 +55,20 @@ def apply_to_frames(self, method, *args, **kwargs):
new_frames[0].save(
write_to, format=self.im.format, save_all=True, append_images=new_frames[1:]
)
return Image.open(write_to)

to_return = Image.open(write_to)
# Animated GIFs are always opened in palette mode (P). We need seek through the
# frames to ensure that to_return has the correct mode.
# Background information:
# https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#gif
if (
to_return.format == "GIF"
and to_return.is_animated
and GifImagePlugin.LOADING_STRATEGY != GifImagePlugin.LoadingStrategy.RGB_ALWAYS
):
for i in range(to_return.n_frames):
to_return.seek(i)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've struggled with Pillow APIs from the beginning, but reading the docs suggests that visiting these frames with seek should fix the mode, ist that correct?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct. Seeking will fix the mode, but the loading behaviour can be modified by global Pillow setting.

I think we could also check GifImagePlugin.LOADING_STRATEGY and skip this second seek loop if it's set correctly. But I'd need to test this. e.g.:

if (
    to_return.format == "GIF" and
    to_return.is_animated and
    GifImagePlugin.LOADING_STRATEGY != GifImagePlugin.LoadingStrategy.RGB_ALWAYS
):
    # ...

Users would just need to set this to avoid the extra seek loop.

from PIL import GifImagePlugin
GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_ALWAYS

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is seeking a big overhead? If it generates too much complexity for a not so big performance gain, I would strive for "settings to disable animation support", as processing all these frames before will be a real performance hit already? But that would be another PR, I guess.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I understand it, seeking through the frames is basically converting the index colors (e.g. palette colors) to the RGB(A) image data in memory.

This is just an edge case for animated GIFs due to how Pillow GifImagePlugin works. For animated GIFs, a FrameAware operation means the frames are read, operation applied and read again (to correct the mode). My view: It's probably not a big deal performance-wise, but if there's a way to reduce compute with an opt-in setting, that's great. I can image an application that works with animated GIFs a lot would want to try to be as efficient as possible and take this opt-in. (My guess is LoadingStrategy.RGB_ALWAYS uses more memory in some cases which is why it's not the default - this really is a decision for application developers to take.)

I made an update to check the loading strategy as well in the if condition. It's also fine for me without that check. I think you have a better understanding of how the animation support fits together, so just let me know what you think is best.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I would keep your solution. This probably needs (very little) documentation?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok cool. I'll add some documentation either later today, or tomorrow morning.

return to_return

def __getattr__(self, key):
method = getattr(self.im, key)
Expand Down
Binary file added easy_thumbnails/tests/files/animated_mode_p.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 24 additions & 2 deletions easy_thumbnails/tests/test_animated_formats.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from io import BytesIO
from PIL import Image, ImageChops, ImageDraw
from PIL import Image, ImageDraw, GifImagePlugin
from easy_thumbnails import processors
from unittest import TestCase
from unittest import TestCase, mock

from easy_thumbnails.files import get_thumbnailer


def create_animated_image(mode='RGB', format="gif", size=(1000, 1000), no_frames=6):
Expand Down Expand Up @@ -81,3 +83,23 @@ def test_background(self):
# indeed processed?
self.assertEqual(frames_count, processed_frames_count)
self.assertEqual(processed.size, (1000, 1800))

def test_gif_with_mode_p(self):
image_path = "easy_thumbnails/tests/files/animated_mode_p.gif"
with open(image_path, "rb") as im:
t = get_thumbnailer(im, image_path)
# Should not fail because of wrong mode and should still be animated.
# https://github.com/SmileyChris/easy-thumbnails/issues/653
thumbnail = t.get_thumbnail({'size': (500, 50), 'crop': True})
self.assertTrue(thumbnail.image.is_animated)

@mock.patch("PIL.GifImagePlugin.LOADING_STRATEGY", GifImagePlugin.LoadingStrategy.RGB_ALWAYS)
def test_gif_with_mode_p__gif_plug_loading_strategy_rgb_always(self):
print("m",GifImagePlugin.LOADING_STRATEGY)
image_path = "easy_thumbnails/tests/files/animated_mode_p.gif"
with open(image_path, "rb") as im:
t = get_thumbnailer(im, image_path)
# Should not fail because of wrong mode and should still be animated.
# https://github.com/SmileyChris/easy-thumbnails/issues/653
thumbnail = t.get_thumbnail({'size': (500, 50), 'crop': True})
self.assertTrue(thumbnail.image.is_animated)
Loading