Skip to content

Use info disposal and blend when calculating APNG frame deltas - #10061

Open
glaziermag wants to merge 5 commits into
python-pillow:mainfrom
glaziermag:fix/apng-info-disposal-delta
Open

glaziermag wants to merge 5 commits into
python-pillow:mainfrom
glaziermag:fix/apng-info-disposal-delta

Conversation

@glaziermag

Copy link
Copy Markdown

If disposal or blend is not passed to save(), the APNG writer uses the im.info value for the fcTL chunk (L1306-L1307). The frame delta that decides how much of each frame is written still reads previous.encoderinfo.get("disposal") (L1236-L1237), which is None there, so it assumes OP_NONE. A frame that changes only part of the canvas is cropped to that part, while the previous frame is disposed to transparent, so the rest of the image disappears.

from PIL import Image

red = Image.new("RGBA", (8, 8), "red")
frame = red.copy()
frame.paste("green", (0, 0, 2, 2))

red.save("explicit.png", save_all=True, append_images=[frame], disposal=1)
red.info["disposal"] = 1
red.save("info.png", save_all=True, append_images=[frame])

for name in ("explicit.png", "info.png"):
    with Image.open(name) as im:
        im.seek(1)
        print(name, im.getpixel((4, 4)))
# explicit.png (255, 0, 0, 255)
# info.png (0, 0, 0, 0)

Since Image.open() sets info["disposal"], this also affects re-saving an opened APNG. Image.open("Tests/images/iss634.apng").save(out, save_all=True) currently changes 40 of the 41 rendered frames. With this change, it round-trips unchanged.

Changes proposed in this pull request:

  • When calculating the delta from the previous APNG frame, fall back to the same disposal and blend defaults that are written to the fcTL chunk. The "test info disposal" part of test_apng_save_disposal already relies on info["disposal"] being used as that default.
  • Add a test that fails on main for OP_BACKGROUND and OP_PREVIOUS.

Tests/test_file_apng.py and Tests/test_file_png.py pass locally (Python 3.14, macOS).

This was found and prepared by an AI agent (Claude Code) working from the glaziermag account.

Generated with Claude Code

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 <noreply@anthropic.com>
@radarhere radarhere added the 🤖-assisted AI-assisted label Sep 26, 2026
@radarhere

Copy link
Copy Markdown
Member

Is there any particular reason there's no test for blend?

@glaziermag

glaziermag commented Sep 29, 2026 •

Copy link
Copy Markdown
Author

Only that I couldn't find a case where it changes the output. In the delta calculation, blend is only used to decide whether an unchanged frame can be merged into the previous one. With a single blend value from info, both sides of that comparison were None on main, so they already matched. I changed it just to keep it consistent with disposal. Saving with blend from info and with the same value as an argument gave identical bytes on main in all 4,800 combinations of disposal, duration and default_image I tried (including unchanged frames), so a blend test would pass both before and after this change.

Your simplified version writes the same bytes as my original commit in all 30,240 cases I tried.

However, I've since found that this PR lets info["disposal"] reach an existing crash. For L, P, 1 and I;16 images, OP_BACKGROUND pastes an RGBA fill onto a frame of a different mode:

from PIL import Image

im = Image.new("L", (8, 8))
im.info["disposal"] = 2
im.save("out.png", save_all=True, append_images=[im.copy()])
# ValueError: images do not match

main already raises this with disposal=2. With this PR it also happens when re-saving an opened L or P APNG that uses OP_BACKGROUND, e.g. one written with disposal=[0, 2], where main instead writes a wrong or missing frame. Using previous.im.convert("RGBA") instead of previous.im.copy() for the OP_BACKGROUND base fixes it, and doesn't change the output for RGBA images. I can add that with a test here if you'd like.

@radarhere

Copy link
Copy Markdown
Member

Sure, add that to this PR as well.

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 <noreply@anthropic.com>
@glaziermag

Copy link
Copy Markdown
Author

Added in f7dc072. Besides fixing the error for L, P, 1 and I;16, this also changes RGB output. After OP_BACKGROUND, the next RGB frame is now compared against a transparent background rather than opaque black, as the APNG spec describes, so black pixels in the cleared area are no longer cropped out. On main, a black RGB frame with a duration after OP_BACKGROUND was merged into the previous frame; the RGB case in the test checks that. RGBA and LA output is unchanged.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🤖-assisted AI-assisted

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants