Skip to content

Smaller animations - #59

Merged
matmen merged 5 commits into
masterfrom
smaller-animations
Oct 16, 2026
Merged

matmen merged 5 commits into
masterfrom
smaller-animations

Conversation

@matmen

@matmen matmen commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

encodeGIF(), encodeAPNG() and encodeWebP() now optimize frames:

  • They composite the frames and store each one as the area that changed since the frame before. Identical frames are merged.
  • Within that area, unchanged pixels become transparent when that compresses better. When the changes are scattered, as in videos, transparent pixels would break the image up, so the area is kept as it is. The choice comes from counting pixel-to-pixel changes, which picks the same option as compressing both versions in almost every case, at a fraction of the cost.
  • Transparency isn't free when an animation is otherwise opaque. It costs an alpha channel in WebP, a palette entry in GIF, and RGBA instead of RGB (or a larger palette) in APNG. That cost is weighed per frame for GIF and WebP, and per animation for APNG.
  • Where blending can't produce a pixel, the frame replaces its area (APNG/WebP). For GIF, the frame before clears the area instead.
  • {optimize: false} stores the frames as they are, as before.

Results, from re-encoding 762 decoded animations (Wikimedia Commons, the Pillow/libwebp/image-rs test suites, and files written by libwebp and Pillow):

bytes, compared with before
GIF 51%
APNG 63%
WebP 54%

Large animations encode faster; small ones take up to about twice as long. A few files still come out up to 9% larger, where the cost estimate is off.

Compatibility: The files show the same animation as before, checked against our decoder and against Pillow/libwebp. They don't decode to the same frames anymore, which is what {optimize: false} is for. This PR also fixes two things decoders disagreed on:

  • the WebP alpha flag now accounts for uncovered or disposed canvas areas
  • GIFs that restore to the background get a transparent index, because Pillow uses it to decide whether the animation has transparency

It also fixes a crash in encodeGIF() for frames whose changed pixels all became transparent.

@matmen matmen added this to the v3.2.0 milestone Oct 16, 2026
@matmen matmen self-assigned this Oct 16, 2026
@matmen matmen added the enhancement New feature or request label Oct 16, 2026
matmen and others added 5 commits October 16, 2026 06:22
The animation encoders composite the frames and store each as the area
that changed since the frame before, and merge identical frames. Within
that area, unchanged pixels become transparent, unless the area as it is
compresses better: when unchanged pixels are scattered (e.g. in videos),
transparent ones break up the image. Which one is chosen by counting the
changes from pixel to pixel, which picks the same as compressing both in
almost every case, at a fraction of the cost.

Pixels that blending can't produce (they get more transparent, or are
semi-transparent where APNG and WebP blending isn't exact) make the frame
replace its area in APNG and WebP. GIF can't replace: the frame before
clears an area covering them instead (disposal "background"), and such
frames get a transparent index, which Pillow and ffmpeg restore to
(browsers restore to transparent anyway).

Re-encoding decoded animations (which have full frames), the files get
33% (GIF), 44% (APNG) and 34% (WebP) of their size, in about the same
time; none got larger. APNGs and WebPs show exactly the same animation as
before, checked against our decoder and Pillow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J
A frame whose changed pixels all became transparent crashed encodeGIF():
after the frame before cleared them, nothing was left to store. It's
now stored as an empty frame that only takes its time.

Sparse frames need transparency, which isn't free when the frame or
animation is opaque otherwise: an alpha channel in WebP, a palette entry
in GIF (one color less to quantize to), and in APNG, where all frames
share one pixel format, RGBA instead of RGB, or a larger palette (8
instead of 4 bit indices, or none at all at the 257th color). WebP and
GIF weigh this per frame, APNG for the whole animation: an opaque one
whose sparse frames don't pay for it gets them filled in again. Such
files got up to 68% larger than before the optimization; on 762
animations (Wikimedia Commons, test suites of Pillow, libwebp and
image-rs, files written by libwebp and Pillow) a few still get up to 9%
larger, where the estimate is off.

The optimizer works on 32-bit pixels, copies opaque runs at once, finds
the pixels blending can't produce while it builds the frame instead of
in a pass of their own, and copies frames onto a cleared canvas instead
of blending them (decoded animations clear every frame): about twice as
fast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MLHc7GW46D6BmwtEM2khgr
A sparse GIF frame needs a palette entry for its unchanged pixels: if
its colors fill the palette already, the quantizer had to merge colors,
and a GIF that was lossless before wasn't anymore. Such frames stay
dense when that fits the palette.

The APNG pixel format check stops at the 257th color when the frames
aren't grey (truecolor either way), and filling sparse frames in again
copies frames that replace their area by rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MLHc7GW46D6BmwtEM2khgr
Checked with Pillow (and libwebp, which Pillow uses for WebP) against
our decoder:

- WebP: the alpha flag also depends on the canvas, not only the frames'
  pixels: where the first frame doesn't cover it or frames are disposed,
  it's transparent. Without the flag, libwebp shows it opaque black.
- GIF: the first frame covers the canvas, decoders disagree on what's
  outside it (browsers show it transparent, Pillow a palette color). If
  frames restore the background, the first frame has a transparent
  index too: Pillow decides by it whether the animation has
  transparency, and restores to the background color otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MLHc7GW46D6BmwtEM2khgr
{optimize: false} stores the frames as they are, as before: the file
decodes to the same frames, and encoding is faster when little changes
from frame to frame. The changelog has the numbers measured on 762
animations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MLHc7GW46D6BmwtEM2khgr
@matmen
matmen force-pushed the smaller-animations branch from 58cc16b to 0e07780 Compare October 16, 2026 07:02
@matmen
matmen merged commit 5670af0 into master Oct 16, 2026
13 checks passed
@matmen
matmen deleted the smaller-animations branch October 16, 2026 07:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant