Repository: python-pillow/Pillow

Issue:
GIF durations should be preserved from frame info
<!--
Thank you for reporting an issue.

Follow these guidelines to ensure your issue is handled properly.

If you have a ...

1.  General question: consider asking the question on Stack Overflow
    with the python-imaging-library tag:

    * https://stackoverflow.com/questions/tagged/python-imaging-library

    Do not ask a question in both places.

    If you think you have found a bug or have an unexplained exception
    then file a bug report here.

2.  Bug report: include a self-contained, copy-pastable example that
    generates the issue if possible. Be concise with code posted.
    Guidelines on how to provide a good bug report:

    * https://stackoverflow.com/help/mcve

    Bug reports which follow these guidelines are easier to diagnose,
    and are often handled much more quickly.

3.  Feature request: do a quick search of existing issues
    to make sure this has not been asked before.

We know asking good questions takes effort, and we appreciate your time.
Thank you.
-->

### What did you do?

Copied an animated GIF:

```python
with Image.open('Tazspin.gif') as im:
    im.save(f'new.gif', save_all=True, optimize=True, interlace=0)
```

### What did you expect to happen?

Hoped the copy would be something like the original Tazspin.gif:

![Tazspin](https://user-images.githubusercontent.com/24783736/166316458-3e8e3bfc-c5ac-4e88-86b2-7dd23c2e7371.gif)

### What actually happened?

Got this:

![new](https://user-images.githubusercontent.com/24783736/166316569-938b85af-8bd5-4bc4-b092-627a1d5b1bae.gif)

### What are your OS, Python and Pillow versions?

* OS: Windows 10
* Python: 3.10.4
* Pillow: 9.1.0 (slightly modified but you'll get a very similar result with stock 9.1.0)


#### Discussion:

Duration has been a problem in the past. I think that in the absence of a `duration=` arg to `.save()`, the durations ("delay time" in the GIF spec) from the individual frames should be preserved in the output.

Maybe something like this: in `_write_multiple_frames()` (in `GifImagePlugin.py`), delete the line

```python
    duration = im.encoderinfo.get("duration", im.info.get("duration"))
```

Then modify the code that sets `im.encoderinfo` from `im_frame.info`:

```python
                for k, v in im_frame.info.items():
                    if k == "transparency" or k == "duration":
                        continue
                    im.encoderinfo.setdefault(k, v)
```

(Note I am including code changed in PR #6176. I am not making a PR out of this because I don't know how to deal with such conflicts and because I am not sure if this is a good fix. Need input from @radarhere.)

And then before the line `if isinstance(duration...`, insert:

```python
            if "duration" in im_frame.info:
                encoderinfo.setdefault("duration", im_frame.info["duration"])
            duration = encoderinfo.get("duration")
```

This seems to give me the desired result:

![new_910_fix_d1](https://user-images.githubusercontent.com/24783736/166322842-d604bcd2-0171-41a9-baa2-0d635298ad06.gif)

I hope it doesn't break anything.

<!--
Please include **code** that reproduces the issue and whenever possible, an **image** that demonstrates the issue. Please upload images to GitHub, not to third-party file hosting sites. If necessary, add the image to a zip or tar archive.

The best reproductions are self-contained scripts with minimal dependencies. If you are using a framework such as Plone, Django, or Buildout, try to replicate the issue just using Pillow.
-->





Discussion:

Resolving patch:
diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py
--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -561,7 +561,7 @@ def _write_single_frame(im, fp, palette):
 
 def _write_multiple_frames(im, fp, palette):
 
-    duration = im.encoderinfo.get("duration", im.info.get("duration"))
+    duration = im.encoderinfo.get("duration")
     disposal = im.encoderinfo.get("disposal", im.info.get("disposal"))
 
     im_frames = []
@@ -579,6 +579,8 @@ def _write_multiple_frames(im, fp, palette):
             encoderinfo = im.encoderinfo.copy()
             if isinstance(duration, (list, tuple)):
                 encoderinfo["duration"] = duration[frame_count]
+            elif duration is None and "duration" in im_frame.info:
+                encoderinfo["duration"] = im_frame.info["duration"]
             if isinstance(disposal, (list, tuple)):
                 encoderinfo["disposal"] = disposal[frame_count]
             frame_count += 1


Test patch:
diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py
--- a/Tests/test_file_gif.py
+++ b/Tests/test_file_gif.py
@@ -3,7 +3,7 @@
 
 import pytest
 
-from PIL import GifImagePlugin, Image, ImageDraw, ImagePalette, features
+from PIL import GifImagePlugin, Image, ImageDraw, ImagePalette, ImageSequence, features
 
 from .helper import (
     assert_image_equal,
@@ -691,6 +691,23 @@ def test_multiple_duration(tmp_path):
                 pass
 
 
+def test_roundtrip_info_duration(tmp_path):
+    duration_list = [100, 500, 500]
+
+    out = str(tmp_path / "temp.gif")
+    with Image.open("Tests/images/transparent_dispose.gif") as im:
+        assert [
+            frame.info["duration"] for frame in ImageSequence.Iterator(im)
+        ] == duration_list
+
+        im.save(out, save_all=True)
+
+    with Image.open(out) as reloaded:
+        assert [
+            frame.info["duration"] for frame in ImageSequence.Iterator(reloaded)
+        ] == duration_list
+
+
 def test_identical_frames(tmp_path):
     duration_list = [1000, 1500, 2000, 4000]