mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-03 21:43:21 +00:00 
			
		
		
		
	emoji: Add support for animated GIF images.
This commit adds 'resize_gif()' function which extracts each frame, resize it and coalesces them again to form the resized GIF while preserving the duration of the GIF. I read some stackoverflow answers all of which were referring to BiggleZX's script (https://gist.github.com/BigglesZX/4016539) for working with animated GIF. I modified the script to fit to our usecase and did some manual testing but the function was failing for some specific GIFs and was not preserving the duration of animation. So I went ahead and read about GIF format itself as well as PIL's `GifImagePlugin` code and came up with this simple function which gets the worked done in a much cleaner way. I tested this function on a number of GIF images from giphy.com and it resized all of them correctly. Fixes: #9945.
This commit is contained in:
		
				
					committed by
					
						
						Tim Abbott
					
				
			
			
				
	
			
			
			
						parent
						
							1d2f8bed92
						
					
				
				
					commit
					25fa9a25ff
				
			
							
								
								
									
										2
									
								
								mypy.ini
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								mypy.ini
									
									
									
									
									
								
							@@ -189,7 +189,7 @@ ignore_missing_imports = True
 | 
			
		||||
[mypy-pika,pika.*]
 | 
			
		||||
ignore_missing_imports = True
 | 
			
		||||
 | 
			
		||||
[mypy-PIL]
 | 
			
		||||
[mypy-PIL,PIL.*]
 | 
			
		||||
ignore_missing_imports = True
 | 
			
		||||
 | 
			
		||||
[mypy-pipeline.storage]
 | 
			
		||||
 
 | 
			
		||||
@@ -27,6 +27,7 @@ import base64
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
from PIL import Image, ImageOps, ExifTags
 | 
			
		||||
from PIL.GifImagePlugin import GifImageFile
 | 
			
		||||
import io
 | 
			
		||||
import random
 | 
			
		||||
import logging
 | 
			
		||||
@@ -117,26 +118,39 @@ def resize_avatar(image_data: bytes, size: int=DEFAULT_AVATAR_SIZE) -> bytes:
 | 
			
		||||
    return out.getvalue()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def resize_gif(im: GifImageFile, size: int=DEFAULT_EMOJI_SIZE) -> bytes:
 | 
			
		||||
    frames = []
 | 
			
		||||
    duration_info = []
 | 
			
		||||
    # If 'loop' info is not set then loop for infinite number of times.
 | 
			
		||||
    loop = im.info.get("loop", 0)
 | 
			
		||||
    for frame_num in range(0, im.n_frames):
 | 
			
		||||
        im.seek(frame_num)
 | 
			
		||||
        new_frame = Image.new("RGBA", im.size)
 | 
			
		||||
        new_frame.paste(im, (0, 0), im.convert("RGBA"))
 | 
			
		||||
        new_frame = ImageOps.fit(new_frame, (size, size), Image.ANTIALIAS)
 | 
			
		||||
        frames.append(new_frame)
 | 
			
		||||
        duration_info.append(im.info['duration'])
 | 
			
		||||
    out = io.BytesIO()
 | 
			
		||||
    frames[0].save(out, save_all=True, optimize=True,
 | 
			
		||||
                   format="GIF", append_images=frames[1:],
 | 
			
		||||
                   duration=duration_info,
 | 
			
		||||
                   loop=loop)
 | 
			
		||||
    return out.getvalue()
 | 
			
		||||
 | 
			
		||||
def resize_emoji(image_data: bytes, size: int=DEFAULT_EMOJI_SIZE) -> bytes:
 | 
			
		||||
    try:
 | 
			
		||||
        im = Image.open(io.BytesIO(image_data))
 | 
			
		||||
        image_format = im.format
 | 
			
		||||
        im = exif_rotate(im)
 | 
			
		||||
        if image_format == 'GIF' and im.is_animated:
 | 
			
		||||
            if im.size[0] != im.size[1]:
 | 
			
		||||
                raise JsonableError(
 | 
			
		||||
                    _("Animated emoji must have the same width and height."))
 | 
			
		||||
            elif im.size[0] > size:
 | 
			
		||||
                raise JsonableError(
 | 
			
		||||
                    _("Animated emoji can't be larger than 64px in width or height."))
 | 
			
		||||
            else:
 | 
			
		||||
                return image_data
 | 
			
		||||
        im = ImageOps.fit(im, (size, size), Image.ANTIALIAS)
 | 
			
		||||
        if image_format == "GIF":
 | 
			
		||||
            return resize_gif(im, size)
 | 
			
		||||
        else:
 | 
			
		||||
            im = exif_rotate(im)
 | 
			
		||||
            im = ImageOps.fit(im, (size, size), Image.ANTIALIAS)
 | 
			
		||||
            out = io.BytesIO()
 | 
			
		||||
            im.save(out, format=image_format)
 | 
			
		||||
            return out.getvalue()
 | 
			
		||||
    except IOError:
 | 
			
		||||
        raise BadImageError("Could not decode image; did you upload an image file?")
 | 
			
		||||
    out = io.BytesIO()
 | 
			
		||||
    im.save(out, format=image_format)
 | 
			
		||||
    return out.getvalue()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
### Common
 | 
			
		||||
 
 | 
			
		||||
@@ -1013,26 +1013,21 @@ class AvatarTest(UploadSerializeMixin, ZulipTestCase):
 | 
			
		||||
        destroy_uploads()
 | 
			
		||||
 | 
			
		||||
class EmojiTest(UploadSerializeMixin, ZulipTestCase):
 | 
			
		||||
    # While testing GIF resizing, we can't test if the final GIF has the same
 | 
			
		||||
    # number of frames as the original one because PIL drops duplicate frames
 | 
			
		||||
    # with a corresponding increase in the duration of the previous frame.
 | 
			
		||||
    def test_resize_emoji(self) -> None:
 | 
			
		||||
        # Test unequal width and height of animated GIF image
 | 
			
		||||
        animated_unequal_img_data = get_test_image_file('animated_unequal_img.gif').read()
 | 
			
		||||
        with self.assertRaises(JsonableError):
 | 
			
		||||
            resize_emoji(animated_unequal_img_data)
 | 
			
		||||
        resized_img_data = resize_emoji(animated_unequal_img_data, size=50)
 | 
			
		||||
        im = Image.open(io.BytesIO(resized_img_data))
 | 
			
		||||
        self.assertEqual((50, 50), im.size)
 | 
			
		||||
 | 
			
		||||
        # Test for large animated image (128x128)
 | 
			
		||||
        animated_large_img_data = get_test_image_file('animated_large_img.gif').read()
 | 
			
		||||
        with self.assertRaises(JsonableError):
 | 
			
		||||
            resize_emoji(animated_large_img_data)
 | 
			
		||||
 | 
			
		||||
        # Test for no resize case
 | 
			
		||||
        animated_img_data = get_test_image_file('animated_img.gif').read()
 | 
			
		||||
        self.assertEqual(animated_img_data, resize_emoji(animated_img_data))
 | 
			
		||||
 | 
			
		||||
        # Test for resize case
 | 
			
		||||
        img_data = get_test_image_file('img.gif').read()
 | 
			
		||||
        resized_img_data = resize_emoji(img_data, size=80)
 | 
			
		||||
        resized_img_data = resize_emoji(animated_large_img_data, size=50)
 | 
			
		||||
        im = Image.open(io.BytesIO(resized_img_data))
 | 
			
		||||
        self.assertEqual((80, 80), im.size)
 | 
			
		||||
        self.assertEqual((50, 50), im.size)
 | 
			
		||||
 | 
			
		||||
        # Test corrupt image exception
 | 
			
		||||
        corrupted_img_data = get_test_image_file('corrupt.gif').read()
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user