Repository: python-pillow/Pillow (python)

Issue:
apng duration returns float instead of int

<!--
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?

Get the duration of an apng file.

### What did you expect to happen?

Get the ms as int. [As documented, setting requires an int.](https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#apng-sequences)

### What actually happened?

Got ms as float.

### What are your OS, Python and Pillow versions?

* OS: Windows 11 Home 24H2
* Python: Python 3.13.9 (tags/v3.13.9:8183fa5, Oct 14 2025, 14:09:13) [MSC v.1944 64 bit (AMD64)] on win32
* Pillow: 12.0.0

```text
--------------------------------------------------------------------
Pillow 12.0.0
Python 3.13.9 (tags/v3.13.9:8183fa5, Oct 14 2025, 14:09:13) [MSC v.1944 64 bit (AMD64)]
--------------------------------------------------------------------
Python executable is D:\code\image viewer\tk_image_viewer\venv\Scripts\python.exe
Environment Python files loaded from D:\code\image viewer\tk_image_viewer\venv
System Python files loaded from C:\Users\C\AppData\Local\Python\pythoncore-3.13-64
--------------------------------------------------------------------
Python Pillow modules loaded from D:\code\image viewer\tk_image_viewer\venv\Lib\site-packages\PIL
Binary Pillow modules loaded from D:\code\image viewer\tk_image_viewer\venv\Lib\site-packages\PIL
--------------------------------------------------------------------
--- PIL CORE support ok, compiled for 12.0.0
--- TKINTER support ok, loaded 8.6
--- FREETYPE2 support ok, loaded 2.14.1
--- LITTLECMS2 support ok, loaded 2.17
--- WEBP support ok, loaded 1.6.0
--- AVIF support ok, loaded 1.3.0
--- JPEG support ok, compiled for libjpeg-turbo 3.1.2
--- OPENJPEG (JPEG2000) support ok, loaded 2.5.4
--- ZLIB (PNG/ZIP) support ok, loaded 1.3.1.zlib-ng, compiled for zlib-ng 2.2.5
--- LIBTIFF support ok, loaded 4.7.1
--- RAQM (Bidirectional Text) support ok, loaded 0.10.3, fribidi 1.0.13, harfbuzz 12.1.0
*** LIBIMAGEQUANT (Quantization method) support not installed
*** XCB (X protocol) support not installed
--------------------------------------------------------------------
```

<!--
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.
-->

```python
from PIL import GifImagePlugin, Image, ImageGrab, ImageTk

im = Image.open(path)
im.load()  # Else im.info = {} for AVIF.
if hasattr(im, "n_frames"):
    n = im.n_frames
    if n > 1:
        APP.info["Frames"] = n
        duration: int = 0
        # for frame in range(n - 1, -1, -1):  # 116 GIF frames in 15.45s vs 0.21s!
        for frame in range(n):
            im.seek(frame)
            duration += im.info.get("duration", 100) or 100
        im.seek(0)
        print(duration)  # should be an int as Tk's `after()` expects
```

[anim-icos.zip](https://github.com/user-attachments/files/24362815/anim-icos.zip)


Patch:
diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst
index 03ee96c0f00..d26c0fbae5c 100644
--- a/docs/handbook/image-file-formats.rst
+++ b/docs/handbook/image-file-formats.rst
@@ -1041,9 +1041,8 @@ following parameters can also be set:
     Defaults to 0.
 
 **duration**
-    Integer (or list or tuple of integers) length of time to display this APNG frame
-    (in milliseconds).
-    Defaults to 0.
+    The length of time (or list or tuple of lengths of time) to display this APNG frame
+    (in milliseconds). Defaults to 0.
 
 **disposal**
     An integer (or list or tuple of integers) specifying the APNG disposal
diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py
index e3588735f20..5f536035a2d 100644
--- a/src/PIL/PngImagePlugin.py
+++ b/src/PIL/PngImagePlugin.py
@@ -39,6 +39,7 @@
 import warnings
 import zlib
 from enum import IntEnum
+from fractions import Fraction
 from typing import IO, NamedTuple, cast
 
 from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence
@@ -1272,7 +1273,11 @@ def _write_multiple_frames(
             im_frame = im_frame.crop(bbox)
         size = im_frame.size
         encoderinfo = frame_data.encoderinfo
-        frame_duration = int(round(encoderinfo.get("duration", 0)))
+        frame_duration = encoderinfo.get("duration", 0)
+        delay = Fraction(frame_duration / 1000).limit_denominator(65535)
+        if delay.numerator > 65535:
+            msg = "cannot write duration"
+            raise ValueError(msg)
         frame_disposal = encoderinfo.get("disposal", disposal)
         frame_blend = encoderinfo.get("blend", blend)
         # frame control
@@ -1284,8 +1289,8 @@ def _write_multiple_frames(
             o32(size[1]),  # height
             o32(bbox[0]),  # x_offset
             o32(bbox[1]),  # y_offset
-            o16(frame_duration),  # delay_numerator
-            o16(1000),  # delay_denominator
+            o16(delay.numerator),  # delay_numerator
+            o16(delay.denominator),  # delay_denominator
             o8(frame_disposal),  # dispose_op
             o8(frame_blend),  # blend_op
         )


Test patch:
diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py
index 644b7807a66..b57a1d1ad8c 100644
--- a/Tests/test_file_apng.py
+++ b/Tests/test_file_apng.py
@@ -518,6 +518,24 @@ def test_apng_save_duration_loop(tmp_path: Path) -> None:
         assert im.info["duration"] == 600
 
 
+def test_apng_save_duration_float(tmp_path: Path) -> None:
+    test_file = tmp_path / "temp.png"
+    im = Image.new("1", (1, 1))
+    im2 = Image.new("1", (1, 1), 1)
+    im.save(test_file, save_all=True, append_images=[im2], duration=0.5)
+
+    with Image.open(test_file) as reloaded:
+        assert reloaded.info["duration"] == 0.5
+
+
+def test_apng_save_large_duration(tmp_path: Path) -> None:
+    test_file = tmp_path / "temp.png"
+    im = Image.new("1", (1, 1))
+    im2 = Image.new("1", (1, 1), 1)
+    with pytest.raises(ValueError, match="cannot write duration"):
+        im.save(test_file, save_all=True, append_images=[im2], duration=65536000)
+
+
 def test_apng_save_disposal(tmp_path: Path) -> None:
     test_file = tmp_path / "temp.png"
     size = (128, 64)