Repository: python-pillow/Pillow

Issue:
Animated PNG filters out similar frames without adjusting frame rate
### What did you do?

Attempted to make an animated PNG from a set of images.

### What did you expect to happen?

To have a working animated PNG, with the right duration/frame rate.

### What actually happened?

The resulting animated PNG only has two frames, instead of 40 frames, and with a very high frame rate.

### What are your OS, Python and Pillow versions?

* OS: macOS 13.3.1
* Python: Python 3.11.3
* Pillow: I have checked out this PR: https://github.com/python-pillow/Pillow/pull/7123, which fixes a related issue.

This code creates a 40 frame animation, 20 frames of a red square and 20 frames of a blue square which run at 20 fps, which should result in a 2 second animation:

```python
#!/usr/bin/env python3

from PIL import Image, ImageDraw

images = []
for i in range(20):
    img = Image.new("RGBA", (300, 300), (255, 255, 255, 255))

    draw = ImageDraw.Draw(img)
    draw.rectangle((100, 100, 200, 200), fill="red")

    img.save(f"frame1-{i}.png")
    images.append(img)

for i in range(20):
    img = Image.new("RGBA", (300, 300), (255, 255, 255, 255))

    draw = ImageDraw.Draw(img)
    draw.rectangle((100, 100, 200, 200), fill="blue")

    img.save(f"frame2-{i}.png")
    images.append(img)

# Should result in a 2 second animation
frame_rate = 20
frame_duration = 1000 / frame_rate

images[0].save(
    "test.png", save_all=True, append_images=images[1:], duration=frame_duration, loop=0
)
images[0].save(
    "test.gif", save_all=True, append_images=images[1:], duration=frame_duration, loop=0
)
```
GIF:
![test](https://user-images.githubusercontent.com/1012677/236952987-25b2b673-4747-44b6-bfea-7395e6fe0823.gif)

PNG:
![test](https://user-images.githubusercontent.com/1012677/236952989-e9e5d5b4-b99a-47b9-8d3f-d452aed370f1.png)

These images actually only contain 2 frames, not 40.

One interesting thing to note is the frame rate of these images shown by `ffprobe`:
```console
$ ffprobe -v quiet -show_entries stream=r_frame_rate,time_base,duration  output.gif
[STREAM]
r_frame_rate=100/1
time_base=1/100
duration=2.100000
[/STREAM]

$ ffprobe -v quiet -show_entries stream=r_frame_rate,time_base,duration output.png
[STREAM]
r_frame_rate=100000/1
time_base=1/100000
duration=N/A
[/STREAM]
```

This is confusing to me, as while the GIF duration is roughly correct, the frame rate is not 100 fps.



Discussion:
Thanks for your concise example. I've created PR #7146 to resolve this.

Resolving patch:
diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py
--- a/src/PIL/PngImagePlugin.py
+++ b/src/PIL/PngImagePlugin.py
@@ -1146,11 +1146,14 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images)
                     and prev_disposal == encoderinfo.get("disposal")
                     and prev_blend == encoderinfo.get("blend")
                 ):
-                    if isinstance(duration, (list, tuple)):
-                        previous["encoderinfo"]["duration"] += encoderinfo["duration"]
+                    previous["encoderinfo"]["duration"] += encoderinfo.get(
+                        "duration", duration
+                    )
                     continue
             else:
                 bbox = None
+            if "duration" not in encoderinfo:
+                encoderinfo["duration"] = duration
             im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo})
 
     # animation control
@@ -1175,7 +1178,7 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images)
             im_frame = im_frame.crop(bbox)
         size = im_frame.size
         encoderinfo = frame_data["encoderinfo"]
-        frame_duration = int(round(encoderinfo.get("duration", duration)))
+        frame_duration = int(round(encoderinfo["duration"]))
         frame_disposal = encoderinfo.get("disposal", disposal)
         frame_blend = encoderinfo.get("blend", blend)
         # frame control


Test patch:
diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py
--- a/Tests/test_file_apng.py
+++ b/Tests/test_file_apng.py
@@ -447,6 +447,17 @@ def test_apng_save_duration_loop(tmp_path):
         assert im.info.get("duration") == 750
 
 
+def test_apng_save_duplicate_duration(tmp_path):
+    test_file = str(tmp_path / "temp.png")
+    frame = Image.new("RGB", (1, 1))
+
+    # Test a single duration is correctly combined across duplicate frames
+    frame.save(test_file, save_all=True, append_images=[frame, frame], duration=500)
+    with Image.open(test_file) as im:
+        assert im.n_frames == 1
+        assert im.info.get("duration") == 1500
+
+
 def test_apng_save_disposal(tmp_path):
     test_file = str(tmp_path / "temp.png")
     size = (128, 64)