Repository: streamlit/streamlit

Issue:
st.image bug in v0.13.0
st.image can display a local GIF correctly in v1.12.2. However, v0.130 displays the gif as a static png. When right-clicking on the image, it can only be saved as a png, whereas in v0.12.2, it can be saved as GIF. 

```python
import streamlit as st
st.image("/tmp/image.gif")
```

![58a0c8f8-6162-4a5b-b0e5-a41430946358](https://user-images.githubusercontent.com/5016453/192338383-2a7b43ef-c279-40f5-ad31-f004b1e1db81.gif)





Discussion:
It is probably related to this commit. 

https://github.com/streamlit/streamlit/compare/1.13.0...develop#diff-d44254193436fb85f9870ba7de0b585ac6ecd29e65049436d54b952c3a41f746L61

![image](https://user-images.githubusercontent.com/5016453/192339511-9013696b-e2b5-4170-aa44-66bc2a4f1e7e.png)


Resolving patch:
diff --git a/e2e/scripts/st_image.py b/e2e/scripts/st_image.py
--- a/e2e/scripts/st_image.py
+++ b/e2e/scripts/st_image.py
@@ -12,13 +12,49 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import io
+
 import numpy as np
+from PIL import Image, ImageDraw
+
+import streamlit as st
+
+
+def create_gif(size, frames=1):
+    # Create grayscale image.
+    im = Image.new("L", (size, size), "white")
+
+    images = []
+
+    # Make circle of a constant size with a number of frames, moving across the
+    # principal diagonal of a 64x64 image. The GIF will not loop and stops
+    # animating after frames x 100ms.
+    for i in range(0, frames):
+        frame = im.copy()
+        draw = ImageDraw.Draw(frame)
+        pos = (i, i)
+        circle_size = size / 2
+        draw.ellipse([pos, tuple(p + circle_size for p in pos)], "black")
+        images.append(frame.copy())
+
+    # Save the frames as an animated GIF
+    data = io.BytesIO()
+    images[0].save(
+        data,
+        format="GIF",
+        save_all=True,
+        append_images=images[1:],
+        duration=1,
+    )
+
+    return data.getvalue()
+
 
 import streamlit as st
 
 img = np.repeat(0, 10000).reshape(100, 100)
 img800 = np.repeat(0, 640000).reshape(800, 800)
-
+gif = create_gif(64, frames=32)
 
 st.image(img, caption="Black Square as JPEG", output_format="JPEG", width=100)
 
@@ -68,3 +104,7 @@
     </svg>
 """
 )
+
+st.image(gif, width=100)
+st.image(create_gif(64), caption="Black Circle as GIF", width=100)
+st.image(gif, caption="GIF as PNG", output_format="PNG", width=100)
diff --git a/lib/streamlit/elements/image.py b/lib/streamlit/elements/image.py
--- a/lib/streamlit/elements/image.py
+++ b/lib/streamlit/elements/image.py
@@ -27,7 +27,7 @@
 from urllib.parse import urlparse
 
 import numpy as np
-from PIL import Image, ImageFile
+from PIL import GifImagePlugin, Image, ImageFile
 from typing_extensions import Final, Literal, TypeAlias
 
 from streamlit import runtime
@@ -51,12 +51,14 @@
 # DPI.
 MAXIMUM_CONTENT_WIDTH: Final[int] = 2 * 730
 
-PILImage: TypeAlias = Union[ImageFile.ImageFile, Image.Image]
+PILImage: TypeAlias = Union[
+    ImageFile.ImageFile, Image.Image, GifImagePlugin.GifImageFile
+]
 AtomicImage: TypeAlias = Union[PILImage, "npt.NDArray[Any]", io.BytesIO, str]
 ImageOrImageList: TypeAlias = Union[AtomicImage, List[AtomicImage]]
 UseColumnWith: TypeAlias = Optional[Union[Literal["auto", "always", "never"], bool]]
 Channels: TypeAlias = Literal["RGB", "BGR"]
-ImageFormat: TypeAlias = Literal["JPEG", "PNG"]
+ImageFormat: TypeAlias = Literal["JPEG", "PNG", "GIF"]
 ImageFormatOrAuto: TypeAlias = Literal[ImageFormat, "auto"]
 
 
@@ -165,15 +167,19 @@ def _image_may_have_alpha_channel(image: PILImage) -> bool:
         return False
 
 
+def _image_is_gif(image: PILImage) -> bool:
+    return bool(image.format == "GIF")
+
+
 def _validate_image_format_string(
     image_data: Union[bytes, PILImage], format: str
 ) -> ImageFormat:
-    """Return either "JPEG" or "PNG", based on the input `format` string.
+    """Return either "JPEG", "PNG", or "GIF", based on the input `format` string.
 
     - If `format` is "JPEG" or "JPG" (or any capitalization thereof), return "JPEG"
     - If `format` is "PNG" (or any capitalization thereof), return "PNG"
     - For all other strings, return "PNG" if the image has an alpha channel,
-      and "JPEG" otherwise.
+    "GIF" if the image is a GIF, and "JPEG" otherwise.
     """
     format = format.upper()
     if format == "JPEG" or format == "PNG":
@@ -188,6 +194,9 @@ def _validate_image_format_string(
     else:
         pil_image = image_data
 
+    if _image_is_gif(pil_image):
+        return "GIF"
+
     if _image_may_have_alpha_channel(pil_image):
         return "PNG"
 


Test patch:
diff --git a/lib/tests/streamlit/elements/image_test.py b/lib/tests/streamlit/elements/image_test.py
--- a/lib/tests/streamlit/elements/image_test.py
+++ b/lib/tests/streamlit/elements/image_test.py
@@ -14,6 +14,8 @@
 
 """Unit tests for st.image and other image.py utility code."""
 
+import io
+import random
 from unittest import mock
 
 import cv2
@@ -79,6 +81,36 @@ def create_image(size, format="RGB", add_alpha=True):
         return image
 
 
+def create_gif(size):
+    # Create grayscale image.
+    im = Image.new("L", (size, size), "white")
+
+    images = []
+
+    # Make ten frames with the circle of a random size and location
+    random.seed(0)
+    for i in range(0, 10):
+        frame = im.copy()
+        draw = ImageDraw.Draw(frame)
+        pos = (random.randrange(0, size), random.randrange(0, size))
+        circle_size = random.randrange(10, size / 2)
+        draw.ellipse([pos, tuple(p + circle_size for p in pos)], "black")
+        images.append(frame.copy())
+
+    # Save the frames as an animated GIF
+    data = io.BytesIO()
+    images[0].save(
+        data,
+        format="GIF",
+        save_all=True,
+        append_images=images[1:],
+        duration=100,
+        loop=0,
+    )
+
+    return data.getvalue()
+
+
 IMAGES = {
     "img_32_32_3_rgb": {
         "pil": create_image(32, "RGB", add_alpha=False),
@@ -96,6 +128,9 @@ def create_image(size, format="RGB", add_alpha=True):
         "pil": Image.new("RGB", (64, 64), color="red"),
         "np": np.array(Image.new("RGB", (64, 64), color="red")),
     },
+    "gif_64_64": {
+        "gif": create_gif(64),
+    },
 }
 
 
@@ -108,6 +143,7 @@ class ImageProtoTest(testutil.DeltaGeneratorTestCase):
             (IMAGES["img_32_32_3_bgr"]["np"], "png"),
             (IMAGES["img_64_64_rgb"]["np"], "jpeg"),
             (IMAGES["img_32_32_3_rgba"]["np"], "jpeg"),
+            (IMAGES["gif_64_64"]["gif"], "gif"),
         ]
     )
     def test_marshall_images(self, data_in: image.AtomicImage, format: str):
@@ -125,10 +161,13 @@ def test_marshall_images(self, data_in: image.AtomicImage, format: str):
         * Bytes
         """
         mimetype = f"image/{format}"
-        file_id = _calculate_file_id(
-            _np_array_to_bytes(data_in, output_format=format),
-            mimetype=mimetype,
-        )
+        if isinstance(data_in, bytes):
+            file_id = _calculate_file_id(data_in, mimetype=mimetype)
+        else:
+            file_id = _calculate_file_id(
+                _np_array_to_bytes(data_in, output_format=format),
+                mimetype=mimetype,
+            )
 
         st.image(data_in, output_format=format)
         imglist = self.get_delta_from_queue().new_element.imgs
@@ -149,6 +188,7 @@ def test_marshall_images(self, data_in: image.AtomicImage, format: str):
             (IMAGES["img_32_32_3_bgr"]["pil"], ".jpg"),
             (IMAGES["img_64_64_rgb"]["pil"], ".jpg"),
             (IMAGES["img_32_32_3_rgba"]["pil"], ".png"),
+            (IMAGES["gif_64_64"]["gif"], ".gif"),
         ]
     )
     def test_marshall_images_with_auto_output_format(
@@ -166,11 +206,12 @@ def test_marshall_images_with_auto_output_format(
     @parameterized.expand(
         [
             (IMAGES["img_32_32_3_rgb"]["np"], "/media/"),
+            (IMAGES["gif_64_64"]["gif"], "/media/"),
             ("https://streamlit.io/test.png", "https://streamlit.io/test.png"),
             ("https://streamlit.io/test.svg", "https://streamlit.io/test.svg"),
         ]
     )
-    def test_image_to_url(self, img, expected_prefix):
+    def test_image_to_url_prefix(self, img, expected_prefix):
         url = image.image_to_url(
             img,
             width=-1,
@@ -181,13 +222,34 @@ def test_image_to_url(self, img, expected_prefix):
         )
         self.assertTrue(url.startswith(expected_prefix))
 
+    @parameterized.expand(
+        [
+            (IMAGES["img_32_32_3_rgb"]["np"], ".jpg"),
+            (IMAGES["gif_64_64"]["gif"], ".gif"),
+            ("https://streamlit.io/test.png", ".png"),
+            ("https://streamlit.io/test.svg", ".svg"),
+        ]
+    )
+    def test_image_to_url_suffix(self, img, expected_suffix):
+        url = image.image_to_url(
+            img,
+            width=-1,
+            clamp=False,
+            channels="RGB",
+            output_format="auto",
+            image_id="blah",
+        )
+        self.assertTrue(url.endswith(expected_suffix))
+
     @parameterized.expand(
         [
             ("foo.png", "image/png", False),
             ("path/to/foo.jpg", "image/jpeg", False),
+            ("path/to/foo.gif", "image/gif", False),
             ("foo.unknown_extension", "application/octet-stream", False),
             ("foo", "application/octet-stream", False),
             ("https://foo.png", "image/png", True),
+            ("https://foo.gif", "image/gif", True),
         ]
     )
     def test_image_to_url_adds_filenames_to_media_file_mgr(
@@ -208,7 +270,7 @@ def test_image_to_url_adds_filenames_to_media_file_mgr(
                 width=-1,
                 clamp=False,
                 channels="RGB",
-                output_format="JPEG",
+                output_format="auto",
                 image_id="mock_image_id",
             )
 
@@ -360,8 +422,8 @@ def test_st_image_with_list_of_urls(self):
         """Test st.image with list of urls."""
         urls = [
             "http://server/fake0.jpg",
-            "http://server/fake1.jpg",
-            "http://server/fake2.jpg",
+            "http://server/fake1.png",
+            "http://server/fake2.gif",
         ]
         st.image(urls, caption=["some caption"] * 3, width=300)