From 13615f7c624bccf17339aa2f29f9af335256f432 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 17:30:43 +0000 Subject: [PATCH] Reduce an animated input to its first frame [patch] An animated GIF, APNG or WebP loads with one frame per step. The Mutate calls applied to every frame, but the coverage pass in ProcessPixelRows touched only the root frame, so the remaining frames were written out as an animated PNG of raw greyscale cropped to the first frame's bounds. ProcessImage now drops every frame after the first before processing, matching the tool's single-frame output. Fixes #119 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LbAGNxbUT888WNAYAvXC2p --- IconHelper.Test/ProcessImageTests.cs | 22 ++++++++++++++++++++++ IconHelper/IconHelper.cs | 9 +++++++++ 2 files changed, 31 insertions(+) diff --git a/IconHelper.Test/ProcessImageTests.cs b/IconHelper.Test/ProcessImageTests.cs index c74152c..d36616f 100644 --- a/IconHelper.Test/ProcessImageTests.cs +++ b/IconHelper.Test/ProcessImageTests.cs @@ -376,4 +376,26 @@ public void PreservesTransparencyOfTheSourceArtwork() Assert.AreEqual(0, image[15, 15].A, "The transparent notch should survive processing."); Assert.AreEqual(255, image[2, 2].A, "The surrounding artwork should remain opaque."); } + + [TestMethod] + public void ReducesAnAnimatedInputToItsFirstFrame() + { + // An animated GIF, APNG or WebP decodes to one frame per animation step. Only the root frame + // used to get the coverage pass, so the rest were written out as an animated PNG of raw + // greyscale, cropped to the root frame's bounds. + using Image image = TestImages.Blank(64, 64); + TestImages.FillRect(image, 20, 20, 20, 20, OpaqueWhite); + using (Image second = TestImages.Blank(64, 64)) + { + TestImages.FillRect(second, 5, 5, 55, 55, OpaqueWhite); + image.Frames.AddFrame(second.Frames.RootFrame); + } + + IconHelper.ProcessImage(image, Color.FromBytes(255, 0, 0), 32, 0); + + Assert.AreEqual(1, image.Frames.Count, "The output should be a single static frame."); + Assert.AreEqual(20, image.Width, "The bounds should come from the first frame's artwork."); + Assert.AreEqual(new Rgba32(255, 0, 0, 255), image[10, 10]); + Assert.AreEqual(new Rgba32(255, 0, 0, 255), image[0, 0]); + } } diff --git a/IconHelper/IconHelper.cs b/IconHelper/IconHelper.cs index 56f9478..d5730ad 100644 --- a/IconHelper/IconHelper.cs +++ b/IconHelper/IconHelper.cs @@ -159,12 +159,21 @@ internal static BatchResult ProcessDirectory(Arguments args, Color color) /// Reduces an icon to a coverage mask: every pixel carries the flat target colour and the shape /// lives entirely in the alpha channel. The result is trimmed of its transparent margins, centred /// on a square canvas and scaled down to at most pixels. + /// An animated input is reduced to its first frame, since the output is a static icon. /// The image is mutated in place. /// internal static void ProcessImage(Image image, Color color, int size, int padding) { Ensure.NotNull(image); + // An animated GIF, APNG or WebP loads with one frame per step. Mutate applies to every frame, + // but ProcessPixelRows below reads and writes the root frame alone, so the other frames would + // skip the coverage pass and be saved as an animated PNG of raw greyscale. Drop them first. + while (image.Frames.Count > 1) + { + image.Frames.RemoveFrame(image.Frames.Count - 1); + } + // The semantic Color stores linear channels as doubles. Encode to sRGB bytes once here rather // than per pixel, both for speed and so the pass below stays plain byte arithmetic. (byte colorR, byte colorG, byte colorB, byte _) = color.ToBytes();