From f8d126c24b6a3639223aa040ce14dc1520b283b2 Mon Sep 17 00:00:00 2001 From: lazerg Date: Tue, 27 Oct 2026 06:10:12 +0500 Subject: [PATCH 1/5] Do not clip I;16 values when comparing APNG frames --- Tests/test_file_apng.py | 25 +++++++++++++++++++++++++ src/PIL/PngImagePlugin.py | 14 ++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index c89dc2d1500..a872e734dd5 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -353,6 +353,31 @@ def test_apng_save_different_palettes(tmp_path: Path) -> None: assert reloaded.mode == "RGBA" +@pytest.mark.parametrize("mode", ("I;16", "I;16B")) +def test_apng_save_i16(mode: str, tmp_path: Path) -> None: + test_file = tmp_path / "temp.png" + im = Image.new(mode, (2, 1), 1000) + second = Image.new(mode, (2, 1), 2000) + second.putpixel((0, 0), 50) + im.save( + test_file, + save_all=True, + append_images=[second, Image.new(mode, (2, 1), 3000)], + duration=100, + ) + + with Image.open(test_file) as reloaded: + assert isinstance(reloaded, PngImagePlugin.PngImageFile) + assert reloaded.n_frames == 3 + + reloaded.seek(1) + assert reloaded.getpixel((0, 0)) == 50 + assert reloaded.getpixel((1, 0)) == 2000 + + reloaded.seek(2) + assert reloaded.getpixel((1, 0)) == 3000 + + def test_apng_chunk_errors() -> None: with Image.open("Tests/images/apng/chunk_no_actl.png") as im: assert isinstance(im, PngImagePlugin.PngImageFile) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index ab480fd0bc9..acc54bf93d2 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1250,6 +1250,20 @@ def _write_multiple_frames( delta = ImageChops.subtract_modulo( im_frame.convert("RGBA"), base_im.convert("RGBA") ) + if mode in ("I;16", "I;16B"): + # Converting to RGBA clips values above 255 + raw_base = ( + previous.im + if prev_disposal == Disposal.OP_BACKGROUND + else base_im + ) + raw_delta = ImageChops.subtract_modulo( + *( + Image.frombytes("LA", frame.size, frame.tobytes()) + for frame in (im_frame, raw_base) + ) + ) + delta = ImageChops.lighter(delta, raw_delta.convert("RGBA")) bbox = delta.getbbox(alpha_only=False) if ( not bbox From fdaffec0af10dbbd8101910093ae010538f00243 Mon Sep 17 00:00:00 2001 From: lazerg Date: Tue, 27 Oct 2026 06:13:30 +0500 Subject: [PATCH 2/5] Simplify raw delta and test merged frames --- Tests/test_file_apng.py | 4 +++- src/PIL/PngImagePlugin.py | 6 ++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index a872e734dd5..d7db35e1e34 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -359,10 +359,12 @@ def test_apng_save_i16(mode: str, tmp_path: Path) -> None: im = Image.new(mode, (2, 1), 1000) second = Image.new(mode, (2, 1), 2000) second.putpixel((0, 0), 50) + third = Image.new(mode, (2, 1), 3000) + third.putpixel((0, 0), 50) im.save( test_file, save_all=True, - append_images=[second, Image.new(mode, (2, 1), 3000)], + append_images=[second, third], duration=100, ) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index acc54bf93d2..f07094dfb5b 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1258,10 +1258,8 @@ def _write_multiple_frames( else base_im ) raw_delta = ImageChops.subtract_modulo( - *( - Image.frombytes("LA", frame.size, frame.tobytes()) - for frame in (im_frame, raw_base) - ) + Image.frombytes("LA", im.size, im_frame.tobytes()), + Image.frombytes("LA", im.size, raw_base.tobytes()), ) delta = ImageChops.lighter(delta, raw_delta.convert("RGBA")) bbox = delta.getbbox(alpha_only=False) From 43a2dae9fc6ddd4425fec32543937408a9f5aa41 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 28 Oct 2026 09:00:55 +1100 Subject: [PATCH 3/5] Only combine frames based on previous frame --- src/PIL/PngImagePlugin.py | 39 ++++++--------------------------------- 1 file changed, 6 insertions(+), 33 deletions(-) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 492455c57c6..597931afcc9 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1233,42 +1233,15 @@ def _write_multiple_frames( if im_frames: previous = im_frames[-1] - prev_disposal = previous.encoderinfo.get("disposal") - prev_blend = previous.encoderinfo.get("blend") - if prev_disposal == Disposal.OP_PREVIOUS and len(im_frames) < 2: - prev_disposal = Disposal.OP_BACKGROUND - - if prev_disposal == Disposal.OP_BACKGROUND: - base_im = previous.im.convert("RGBA") - box = previous.bbox or (0, 0, *im.size) - dispose = Image.new("RGBA", (box[2] - box[0], box[3] - box[1])) - base_im.paste(dispose, box) - elif prev_disposal == Disposal.OP_PREVIOUS: - base_im = im_frames[-2].im - else: - base_im = previous.im - delta = ImageChops.subtract_modulo( - im_frame.convert("RGBA"), base_im.convert("RGBA") - ) - if mode in ("I;16", "I;16B"): - # Converting to RGBA clips values above 255 - raw_base = ( - previous.im - if prev_disposal == Disposal.OP_BACKGROUND - else base_im - ) - raw_delta = ImageChops.subtract_modulo( + if mode.startswith("I;16"): + delta = ImageChops.subtract_modulo( Image.frombytes("LA", im.size, im_frame.tobytes()), - Image.frombytes("LA", im.size, raw_base.tobytes()), + Image.frombytes("LA", im.size, previous.im.tobytes()), ) - delta = ImageChops.lighter(delta, raw_delta.convert("RGBA")) + else: + delta = ImageChops.subtract_modulo(im_frame, previous.im) bbox = delta.getbbox(alpha_only=False) - if ( - not bbox - and prev_disposal == encoderinfo.get("disposal") - and prev_blend == encoderinfo.get("blend") - and "duration" in encoderinfo - ): + if not bbox and "duration" in encoderinfo: new_duration = ( previous.encoderinfo["duration"] + encoderinfo["duration"] ) From 10241a7e3774ccf54d34dded10cce60577fcffc2 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 28 Oct 2026 09:01:05 +1100 Subject: [PATCH 4/5] Simplify test --- Tests/test_file_apng.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 404886ab04b..a632b938256 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -356,28 +356,21 @@ def test_apng_save_different_palettes(tmp_path: Path) -> None: @pytest.mark.parametrize("mode", ("I;16", "I;16B")) def test_apng_save_i16(mode: str, tmp_path: Path) -> None: test_file = tmp_path / "temp.png" - im = Image.new(mode, (2, 1), 1000) - second = Image.new(mode, (2, 1), 2000) - second.putpixel((0, 0), 50) - third = Image.new(mode, (2, 1), 3000) - third.putpixel((0, 0), 50) + im = Image.new(mode, (1, 1), 1000) + second = Image.new(mode, (1, 1), 2000) im.save( test_file, save_all=True, - append_images=[second, third], - duration=100, + append_images=[second], ) with Image.open(test_file) as reloaded: assert isinstance(reloaded, PngImagePlugin.PngImageFile) - assert reloaded.n_frames == 3 + assert reloaded.n_frames == 2 + assert reloaded.getpixel((0, 0)) == 1000 reloaded.seek(1) - assert reloaded.getpixel((0, 0)) == 50 - assert reloaded.getpixel((1, 0)) == 2000 - - reloaded.seek(2) - assert reloaded.getpixel((1, 0)) == 3000 + assert reloaded.getpixel((0, 0)) == 2000 def test_apng_chunk_errors() -> None: From a6b4d8af9b7f1ae6117efdb8855872a10267a451 Mon Sep 17 00:00:00 2001 From: lazerg Date: Wed, 28 Oct 2026 07:03:28 +0500 Subject: [PATCH 5/5] Set duration in test so identical frames would be combined --- Tests/test_file_apng.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index a632b938256..fb47b53533e 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -362,6 +362,7 @@ def test_apng_save_i16(mode: str, tmp_path: Path) -> None: test_file, save_all=True, append_images=[second], + duration=100, ) with Image.open(test_file) as reloaded: