From 4e1bb35341edad8b0350bc7e00744f8f57a605aa Mon Sep 17 00:00:00 2001 From: Viliam Mihalik Date: Thu, 8 Jan 2026 10:53:08 +0100 Subject: [PATCH] Fix: Preserve loop attribute for animated images in processors Animated GIFs processed with the background processor (and other processors) were losing the loop attribute, causing them to loop only once instead of preserving the original loop count. Changes: - Preserve loop attribute in FrameAware.apply_to_frames() - Preserve loop attribute in colorspace processor when replace_alpha is used with animated images - Preserve loop attribute in background processor for animated images - Add test_background_loop_preserved() to verify loop preservation Fixes issue where animated GIFs would ignore their loop setting and only loop once after processing with the background processor. --- easy_thumbnails/processors.py | 9 +++++--- .../tests/test_animated_formats.py | 23 +++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/easy_thumbnails/processors.py b/easy_thumbnails/processors.py index 7f7ba20c..223e93d7 100644 --- a/easy_thumbnails/processors.py +++ b/easy_thumbnails/processors.py @@ -52,8 +52,9 @@ def apply_to_frames(self, method, *args, **kwargs): self.im.seek(i) new_frames.append(method(*args, **kwargs)) write_to = BytesIO() + loop = self.im.info.get('loop', 0) new_frames[0].save( - write_to, format=self.im.format, save_all=True, append_images=new_frames[1:] + write_to, format=self.im.format, save_all=True, append_images=new_frames[1:], loop=loop ) return Image.open(write_to) @@ -104,6 +105,7 @@ def colorspace(im, bw=False, replace_alpha=False, **kwargs): im = base else: frames = [] + loop = im.info.get('loop', 0) for i in range(im.n_frames): im.seek(i) if im.mode != 'RGBA': @@ -113,7 +115,7 @@ def colorspace(im, bw=False, replace_alpha=False, **kwargs): frames.append(base) write_to = BytesIO() frames[0].save( - write_to, format=im.format, save_all=True, append_images=frames[1:] + write_to, format=im.format, save_all=True, append_images=frames[1:], loop=loop ) return Image.open(write_to) else: @@ -376,6 +378,7 @@ def background(im, size, background=None, **kwargs): return new_im else: frames = [] + loop = im.info.get('loop', 0) for i in range(im.n_frames): im.seek(i) copied_new_im = new_im.copy() @@ -383,6 +386,6 @@ def background(im, size, background=None, **kwargs): frames.append(copied_new_im) write_to = BytesIO() frames[0].save( - write_to, format=im.format, save_all=True, append_images=frames[1:] + write_to, format=im.format, save_all=True, append_images=frames[1:], loop=loop ) return Image.open(write_to) diff --git a/easy_thumbnails/tests/test_animated_formats.py b/easy_thumbnails/tests/test_animated_formats.py index f3cf81b9..ee720218 100644 --- a/easy_thumbnails/tests/test_animated_formats.py +++ b/easy_thumbnails/tests/test_animated_formats.py @@ -4,7 +4,7 @@ from unittest import TestCase -def create_animated_image(mode='RGB', format="gif", size=(1000, 1000), no_frames=6): +def create_animated_image(mode='RGB', format="gif", size=(1000, 1000), no_frames=6, loop=None): frames = [] for i in range(no_frames): image = Image.new(mode, size, (255, 255, 255)) @@ -14,9 +14,10 @@ def create_animated_image(mode='RGB', format="gif", size=(1000, 1000), no_frames draw.rectangle((x_bit * 2, y_bit, x_bit * 3, y_bit * 8), 'yellow') frames.append(image) write_to = BytesIO() - frames[0].save( - write_to, format=format, save_all=True, append_images=frames[1:] - ) + save_kwargs = {'format': format, 'save_all': True, 'append_images': frames[1:]} + if loop is not None: + save_kwargs['loop'] = loop + frames[0].save(write_to, **save_kwargs) im = Image.open(write_to) # for debugging # with open(f"animated{no_frames}.{format}", "wb") as f: @@ -81,3 +82,17 @@ def test_background(self): # indeed processed? self.assertEqual(frames_count, processed_frames_count) self.assertEqual(processed.size, (1000, 1800)) + + def test_background_loop_preserved(self): + no_frames = 5 + loop_value = 3 + im = create_animated_image(format="gif", no_frames=no_frames, loop=loop_value) + + original_loop = im.info.get('loop', 0) + self.assertEqual(original_loop, loop_value) + + processed = processors.background(im, background="#00ff00", size=(1200, 1200)) + + processed_loop = processed.info.get('loop', 0) + self.assertEqual(processed_loop, original_loop) + self.assertEqual(processed_loop, loop_value)