From 702e72039d088d7a8d6f1c8d0c9dfe7a956f9a3d Mon Sep 17 00:00:00 2001 From: glaziermag Date: Fri, 25 Sep 2026 17:19:17 -0700 Subject: [PATCH 1/4] Use info disposal and blend when calculating APNG frame deltas When disposal or blend were not passed to save(), the fcTL chunk used the im.info fallback, but the frame delta assumed OP_NONE and OP_SOURCE, so frames were cropped against the wrong base image. Co-Authored-By: Claude Opus 5.5 --- Tests/test_file_apng.py | 19 +++++++++++++++++++ src/PIL/PngImagePlugin.py | 8 ++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 15689476c91..487ac56d89a 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -686,6 +686,25 @@ def test_apng_save_disposal_previous(tmp_path: Path) -> None: assert im.getpixel((64, 32)) == (0, 255, 0, 255) +@pytest.mark.parametrize( + "disposal", + (PngImagePlugin.Disposal.OP_BACKGROUND, PngImagePlugin.Disposal.OP_PREVIOUS), +) +def test_apng_save_info_disposal(tmp_path: Path, disposal: int) -> None: + test_file = tmp_path / "temp.png" + red = Image.new("RGBA", (128, 64), (255, 0, 0, 255)) + red_green = red.copy() + red_green.paste((0, 255, 0, 255), (0, 0, 10, 10)) + + # disposal from info should give the same result as disposal as an argument + red.info["disposal"] = disposal + red.save(test_file, save_all=True, append_images=[red_green]) + with Image.open(test_file) as im: + im.seek(1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (255, 0, 0, 255) + + def test_apng_save_blend(tmp_path: Path) -> None: test_file = tmp_path / "temp.png" size = (128, 64) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 56f64f927e4..9cfc365a1be 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1233,8 +1233,8 @@ def _write_multiple_frames( if im_frames: previous = im_frames[-1] - prev_disposal = previous.encoderinfo.get("disposal") - prev_blend = previous.encoderinfo.get("blend") + prev_disposal = previous.encoderinfo.get("disposal", disposal) + prev_blend = previous.encoderinfo.get("blend", blend) if prev_disposal == Disposal.OP_PREVIOUS and len(im_frames) < 2: prev_disposal = Disposal.OP_BACKGROUND @@ -1257,8 +1257,8 @@ def _write_multiple_frames( bbox = delta.getbbox(alpha_only=False) if ( not bbox - and prev_disposal == encoderinfo.get("disposal") - and prev_blend == encoderinfo.get("blend") + and prev_disposal == encoderinfo.get("disposal", disposal) + and prev_blend == encoderinfo.get("blend", blend) and "duration" in encoderinfo ): previous.encoderinfo["duration"] += encoderinfo["duration"] From aa4ca2153a8e66ee6ad18577e49832b4c1d50b94 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 29 Sep 2026 14:12:46 +1000 Subject: [PATCH 2/4] Simplify code --- Tests/test_file_apng.py | 13 ++++--------- src/PIL/PngImagePlugin.py | 24 ++++++++++++++---------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 487ac56d89a..f04ad1028f1 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -686,23 +686,18 @@ def test_apng_save_disposal_previous(tmp_path: Path) -> None: assert im.getpixel((64, 32)) == (0, 255, 0, 255) -@pytest.mark.parametrize( - "disposal", - (PngImagePlugin.Disposal.OP_BACKGROUND, PngImagePlugin.Disposal.OP_PREVIOUS), -) -def test_apng_save_info_disposal(tmp_path: Path, disposal: int) -> None: +def test_apng_save_info_disposal(tmp_path: Path) -> None: test_file = tmp_path / "temp.png" - red = Image.new("RGBA", (128, 64), (255, 0, 0, 255)) + red = Image.new("RGBA", (32, 32), (255, 0, 0, 255)) red_green = red.copy() red_green.paste((0, 255, 0, 255), (0, 0, 10, 10)) - # disposal from info should give the same result as disposal as an argument - red.info["disposal"] = disposal + red.info["disposal"] = PngImagePlugin.Disposal.OP_PREVIOUS red.save(test_file, save_all=True, append_images=[red_green]) with Image.open(test_file) as im: im.seek(1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) - assert im.getpixel((64, 32)) == (255, 0, 0, 255) + assert im.getpixel((20, 20)) == (255, 0, 0, 255) def test_apng_save_blend(tmp_path: Path) -> None: diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 9cfc365a1be..9b32f1a969a 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1225,16 +1225,20 @@ def _write_multiple_frames( 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] - if isinstance(blend, (list, tuple)): - encoderinfo["blend"] = blend[frame_count] + encoderinfo["disposal"] = ( + disposal[frame_count] + if isinstance(disposal, (list, tuple)) + else disposal + ) + encoderinfo["blend"] = ( + blend[frame_count] if isinstance(blend, (list, tuple)) else blend + ) frame_count += 1 if im_frames: previous = im_frames[-1] - prev_disposal = previous.encoderinfo.get("disposal", disposal) - prev_blend = previous.encoderinfo.get("blend", blend) + prev_disposal = previous.encoderinfo["disposal"] + prev_blend = previous.encoderinfo["blend"] if prev_disposal == Disposal.OP_PREVIOUS and len(im_frames) < 2: prev_disposal = Disposal.OP_BACKGROUND @@ -1257,8 +1261,8 @@ def _write_multiple_frames( bbox = delta.getbbox(alpha_only=False) if ( not bbox - and prev_disposal == encoderinfo.get("disposal", disposal) - and prev_blend == encoderinfo.get("blend", blend) + and prev_disposal == encoderinfo["disposal"] + and prev_blend == encoderinfo["blend"] and "duration" in encoderinfo ): previous.encoderinfo["duration"] += encoderinfo["duration"] @@ -1303,8 +1307,8 @@ def _write_multiple_frames( if delay.numerator > 65535: msg = "cannot write duration" raise ValueError(msg) - frame_disposal = encoderinfo.get("disposal", disposal) - frame_blend = encoderinfo.get("blend", blend) + frame_disposal = encoderinfo["disposal"] + frame_blend = encoderinfo["blend"] # frame control chunk( fp, From f7dc072442781c848c4c34da39b639a0d1b615f6 Mon Sep 17 00:00:00 2001 From: glaziermag Date: Mon, 28 Sep 2026 22:47:41 -0700 Subject: [PATCH 3/4] Convert to RGBA before applying APNG OP_BACKGROUND disposal The disposal fill is RGBA, so pasting it onto an L, P, 1 or I;16 frame raised "images do not match", and for RGB it left opaque black instead of the transparent background that the APNG spec uses. Co-Authored-By: Claude Opus 5.5 --- Tests/test_file_apng.py | 17 +++++++++++++++++ src/PIL/PngImagePlugin.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index f04ad1028f1..1e3ce817e6e 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -686,6 +686,23 @@ def test_apng_save_disposal_previous(tmp_path: Path) -> None: assert im.getpixel((64, 32)) == (0, 255, 0, 255) +@pytest.mark.parametrize("mode", ("L", "RGB")) +def test_apng_save_disposal_background_mode(tmp_path: Path, mode: str) -> None: + test_file = tmp_path / "temp.png" + im = Image.new(mode, (32, 32), 128) + + # the black frame is different from the transparent background + im.save( + test_file, + save_all=True, + append_images=[Image.new(mode, (32, 32))], + disposal=PngImagePlugin.Disposal.OP_BACKGROUND, + duration=100, + ) + with Image.open(test_file) as reloaded: + assert reloaded.n_frames == 2 + + def test_apng_save_info_disposal(tmp_path: Path) -> None: test_file = tmp_path / "temp.png" red = Image.new("RGBA", (32, 32), (255, 0, 0, 255)) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 9b32f1a969a..da8eceaf08f 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1243,7 +1243,7 @@ def _write_multiple_frames( prev_disposal = Disposal.OP_BACKGROUND if prev_disposal == Disposal.OP_BACKGROUND: - base_im = previous.im.copy() + base_im = previous.im.convert("RGBA") dispose = Image.core.fill("RGBA", im.size, (0, 0, 0, 0)) bbox = previous.bbox if bbox: From 98e725fc446f529b78828f5f302a14e9ab78e259 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 29 Sep 2026 22:08:02 +1000 Subject: [PATCH 4/4] Lint fix --- 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 1e3ce817e6e..1b253916cf6 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -700,6 +700,7 @@ def test_apng_save_disposal_background_mode(tmp_path: Path, mode: str) -> None: duration=100, ) with Image.open(test_file) as reloaded: + assert isinstance(reloaded, PngImagePlugin.PngImageFile) assert reloaded.n_frames == 2