// Copyright (c) Six Labors. // Licensed under the Six Labors Split License. using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Gif; using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing.Processors.Quantization; using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; // ReSharper disable InconsistentNaming namespace SixLabors.ImageSharp.Tests.Formats.Gif; [Trait("Format", "Gif")] public class GifEncoderTests { private const PixelTypes TestPixelTypes = PixelTypes.Rgba32 | PixelTypes.RgbaVector | PixelTypes.Argb32; private static readonly ImageComparer ValidatorComparer = ImageComparer.TolerantPercentage(0.0015F); public static readonly TheoryData RatioFiles = new() { { TestImages.Gif.Rings, (int)ImageMetadata.DefaultHorizontalResolution, (int)ImageMetadata.DefaultVerticalResolution, PixelResolutionUnit.PixelsPerInch }, { TestImages.Gif.Ratio1x4, 1, 4, PixelResolutionUnit.AspectRatio }, { TestImages.Gif.Ratio4x1, 4, 1, PixelResolutionUnit.AspectRatio } }; public GifEncoderTests() { // Free the pool on 32 bit: if (!TestEnvironment.Is64BitProcess) { Configuration.Default.MemoryAllocator.ReleaseRetainedResources(); } } [Fact] public void GifEncoderDefaultInstanceHasNullQuantizer() => Assert.Null(new GifEncoder().Quantizer); [Theory] [WithTestPatternImages(100, 100, TestPixelTypes, false)] [WithTestPatternImages(100, 100, TestPixelTypes, true)] public void EncodeGeneratedPatterns(TestImageProvider provider, bool limitAllocationBuffer) where TPixel : unmanaged, IPixel { if (limitAllocationBuffer) { provider.LimitAllocatorBufferCapacity().InPixelsSqrt(100); } using (Image image = provider.GetImage()) { GifEncoder encoder = new() { // Use the palette quantizer without dithering to ensure results // are consistent Quantizer = new WebSafePaletteQuantizer(new QuantizerOptions { Dither = null, TransparencyThreshold = 0 }) }; // Always save as we need to compare the encoded output. provider.Utility.SaveTestOutputFile(image, "gif", encoder); } // Compare encoded result string path = provider.Utility.GetTestOutputFileName("gif", null, true); using Image encoded = Image.Load(path); encoded.CompareToReferenceOutput(ValidatorComparer, provider, null, "gif"); } [Theory] [MemberData(nameof(RatioFiles))] public void Encode_PreserveRatio(string imagePath, int xResolution, int yResolution, PixelResolutionUnit resolutionUnit) { GifEncoder options = new(); TestFile testFile = TestFile.Create(imagePath); using Image input = testFile.CreateRgba32Image(); using MemoryStream memStream = new(); input.Save(memStream, options); memStream.Position = 0; using Image output = Image.Load(memStream); ImageMetadata meta = output.Metadata; Assert.Equal(xResolution, meta.HorizontalResolution); Assert.Equal(yResolution, meta.VerticalResolution); Assert.Equal(resolutionUnit, meta.ResolutionUnits); } [Fact] public void Encode_IgnoreMetadataIsFalse_CommentsAreWritten() { GifEncoder options = new(); TestFile testFile = TestFile.Create(TestImages.Gif.Rings); using Image input = testFile.CreateRgba32Image(); using MemoryStream memStream = new(); input.Save(memStream, options); memStream.Position = 0; using Image output = Image.Load(memStream); GifMetadata metadata = output.Metadata.GetGifMetadata(); Assert.Equal(1, metadata.Comments.Count); Assert.Equal("ImageSharp", metadata.Comments[0]); } [Theory] [WithFile(TestImages.Gif.Cheers, PixelTypes.Rgba32)] public void EncodeGlobalPaletteReturnsSmallerFile(TestImageProvider provider) where TPixel : unmanaged, IPixel { using Image image = provider.GetImage(); GifEncoder encoder = new() { ColorTableMode = FrameColorTableMode.Global, Quantizer = new OctreeQuantizer(new QuantizerOptions { Dither = null }) }; // Always save as we need to compare the encoded output. provider.Utility.SaveTestOutputFile(image, "gif", encoder, "global"); encoder = new GifEncoder { ColorTableMode = FrameColorTableMode.Local, Quantizer = new OctreeQuantizer(new QuantizerOptions { Dither = null }), }; provider.Utility.SaveTestOutputFile(image, "gif", encoder, "local"); FileInfo fileInfoGlobal = new(provider.Utility.GetTestOutputFileName("gif", "global")); FileInfo fileInfoLocal = new(provider.Utility.GetTestOutputFileName("gif", "local")); Assert.True(fileInfoGlobal.Length < fileInfoLocal.Length); } [Theory] [WithFile(TestImages.Gif.GlobalQuantizationTest, PixelTypes.Rgba32, 427500, 0.1)] [WithFile(TestImages.Gif.GlobalQuantizationTest, PixelTypes.Rgba32, 200000, 0.1)] [WithFile(TestImages.Gif.GlobalQuantizationTest, PixelTypes.Rgba32, 100000, 0.1)] [WithFile(TestImages.Gif.GlobalQuantizationTest, PixelTypes.Rgba32, 50000, 0.1)] [WithFile(TestImages.Gif.Cheers, PixelTypes.Rgba32, 4000000, 0.01)] [WithFile(TestImages.Gif.Cheers, PixelTypes.Rgba32, 1000000, 0.01)] public void Encode_GlobalPalette_DefaultPixelSamplingStrategy(TestImageProvider provider, int maxPixels, double scanRatio) where TPixel : unmanaged, IPixel { using Image image = provider.GetImage(); GifEncoder encoder = new() { ColorTableMode = FrameColorTableMode.Global, PixelSamplingStrategy = new DefaultPixelSamplingStrategy(maxPixels, scanRatio) }; string testOutputFile = provider.Utility.SaveTestOutputFile( image, "gif", encoder, testOutputDetails: $"{maxPixels}_{scanRatio}", appendPixelTypeToFileName: false); // TODO: For proper regression testing of gifs, use a multi-frame reference output, or find a working reference decoder. // IImageDecoder referenceDecoder = TestEnvironment.GetReferenceDecoder(testOutputFile); // using var encoded = Image.Load(testOutputFile, referenceDecoder); // ValidatorComparer.VerifySimilarity(image, encoded); } [Fact] public void NonMutatingEncodePreservesPaletteCount() { using MemoryStream inStream = new(TestFile.Create(TestImages.Gif.Leo).Bytes); using MemoryStream outStream = new(); inStream.Position = 0; Image image = Image.Load(inStream); GifMetadata metaData = image.Metadata.GetGifMetadata(); GifFrameMetadata frameMetadata = image.Frames.RootFrame.Metadata.GetGifMetadata(); FrameColorTableMode colorMode = metaData.ColorTableMode; int maxColors; if (colorMode == FrameColorTableMode.Global) { maxColors = metaData.GlobalColorTable.Value.Length; } else { maxColors = frameMetadata.LocalColorTable.Value.Length; } GifEncoder encoder = new() { ColorTableMode = colorMode, Quantizer = new OctreeQuantizer(new QuantizerOptions { MaxColors = maxColors }) }; image.Save(outStream, encoder); outStream.Position = 0; outStream.Position = 0; Image clone = Image.Load(outStream); GifMetadata cloneMetadata = clone.Metadata.GetGifMetadata(); Assert.Equal(metaData.ColorTableMode, cloneMetadata.ColorTableMode); // Gifiddle and Cyotek GifInfo say this image has 64 colors. colorMode = cloneMetadata.ColorTableMode; if (colorMode == FrameColorTableMode.Global) { maxColors = metaData.GlobalColorTable.Value.Length; } else { maxColors = frameMetadata.LocalColorTable.Value.Length; } Assert.Equal(64, maxColors); for (int i = 0; i < image.Frames.Count; i++) { GifFrameMetadata iMeta = image.Frames[i].Metadata.GetGifMetadata(); GifFrameMetadata cMeta = clone.Frames[i].Metadata.GetGifMetadata(); if (iMeta.ColorTableMode == FrameColorTableMode.Local) { Assert.Equal(iMeta.LocalColorTable.Value.Length, cMeta.LocalColorTable.Value.Length); } Assert.Equal(iMeta.FrameDelay, cMeta.FrameDelay); } image.Dispose(); clone.Dispose(); } [Theory] [WithFile(TestImages.Gif.Issues.Issue2288_A, PixelTypes.Rgba32)] [WithFile(TestImages.Gif.Issues.Issue2288_B, PixelTypes.Rgba32)] [WithFile(TestImages.Gif.Issues.Issue2288_C, PixelTypes.Rgba32)] [WithFile(TestImages.Gif.Issues.Issue2288_D, PixelTypes.Rgba32)] public void OptionalExtensionsShouldBeHandledProperly(TestImageProvider provider) where TPixel : unmanaged, IPixel { using Image image = provider.GetImage(); provider.Utility.SaveTestOutputFile(image, extension: "gif"); using FileStream fs = File.OpenRead(provider.Utility.GetTestOutputFileName("gif")); using Image image2 = Image.Load(fs); Assert.Equal(image.Frames.Count, image2.Frames.Count); } [Theory] [WithFile(TestImages.Png.APng, PixelTypes.Rgba32)] public void Encode_AnimatedFormatTransform_FromPng(TestImageProvider provider) where TPixel : unmanaged, IPixel { if (TestEnvironment.RunsOnCI && !TestEnvironment.IsWindows) { return; } using Image image = provider.GetImage(PngDecoder.Instance); using MemoryStream memStream = new(); image.Save(memStream, new GifEncoder()); memStream.Position = 0; using Image output = Image.Load(memStream); // TODO: Find a better way to compare. // The image has been visually checked but the quantization and frame trimming pattern used in the gif encoder // means we cannot use an exact comparison nor replicate using the quantizing processor. ImageComparer.TolerantPercentage(1.51f).VerifySimilarity(output, image); PngMetadata png = image.Metadata.GetPngMetadata(); GifMetadata gif = output.Metadata.GetGifMetadata(); Assert.Equal(png.RepeatCount, gif.RepeatCount); for (int i = 0; i < image.Frames.Count; i++) { PngFrameMetadata pngF = image.Frames[i].Metadata.GetPngMetadata(); GifFrameMetadata gifF = output.Frames[i].Metadata.GetGifMetadata(); Assert.Equal((int)(pngF.FrameDelay.ToDouble() * 100), gifF.FrameDelay); switch (pngF.DisposalMode) { case FrameDisposalMode.RestoreToBackground: Assert.Equal(FrameDisposalMode.RestoreToBackground, gifF.DisposalMode); break; case FrameDisposalMode.DoNotDispose: default: Assert.Equal(FrameDisposalMode.DoNotDispose, gifF.DisposalMode); break; } } } [Theory] [WithFile(TestImages.Webp.Lossless.Animated, PixelTypes.Rgba32)] public void Encode_AnimatedFormatTransform_FromWebp(TestImageProvider provider) where TPixel : unmanaged, IPixel { if (TestEnvironment.RunsOnCI && !TestEnvironment.IsWindows) { return; } using Image image = provider.GetImage(WebpDecoder.Instance); using MemoryStream memStream = new(); image.Save(memStream, new GifEncoder()); memStream.Position = 0; using Image output = Image.Load(memStream); image.Save(provider.Utility.GetTestOutputFileName("gif"), new GifEncoder()); // TODO: Find a better way to compare. // The image has been visually checked but the quantization and frame trimming pattern used in the gif encoder // means we cannot use an exact comparison nor replicate using the quantizing processor. ImageComparer.TolerantPercentage(0.776f).VerifySimilarity(output, image); WebpMetadata webp = image.Metadata.GetWebpMetadata(); GifMetadata gif = output.Metadata.GetGifMetadata(); Assert.Equal(webp.RepeatCount, gif.RepeatCount); for (int i = 0; i < image.Frames.Count; i++) { WebpFrameMetadata webpF = image.Frames[i].Metadata.GetWebpMetadata(); GifFrameMetadata gifF = output.Frames[i].Metadata.GetGifMetadata(); Assert.Equal(webpF.FrameDelay, (uint)(gifF.FrameDelay * 10)); switch (webpF.DisposalMode) { case FrameDisposalMode.RestoreToBackground: Assert.Equal(FrameDisposalMode.RestoreToBackground, gifF.DisposalMode); break; case FrameDisposalMode.DoNotDispose: default: Assert.Equal(FrameDisposalMode.DoNotDispose, gifF.DisposalMode); break; } } } public static string[] Animated => TestImages.Gif.Animated; [Theory(Skip = "Enable for visual animated testing")] [WithFileCollection(nameof(Animated), PixelTypes.Rgba32)] public void Encode_Animated_VisualTest(TestImageProvider provider) where TPixel : unmanaged, IPixel { using Image image = provider.GetImage(); provider.Utility.SaveTestOutputFile(image, "webp", new WebpEncoder() { FileFormat = WebpFileFormatType.Lossless }, "animated"); provider.Utility.SaveTestOutputFile(image, "webp", new WebpEncoder() { FileFormat = WebpFileFormatType.Lossy }, "animated-lossy"); provider.Utility.SaveTestOutputFile(image, "png", new PngEncoder(), "animated"); provider.Utility.SaveTestOutputFile(image, "gif", new GifEncoder(), "animated"); } [Fact] public void Encode_WithTransparentColorBehaviorClear_Works() { // arrange using Image image = new(50, 50); GifEncoder encoder = new() { TransparentColorMode = TransparentColorMode.Clear, }; Rgba32 rgba32 = Color.Blue.ToPixel(); image.ProcessPixelRows(accessor => { for (int y = 0; y < image.Height; y++) { Span rowSpan = accessor.GetRowSpan(y); // Half of the test image should be transparent. if (y > 25) { rgba32.A = 0; } for (int x = 0; x < image.Width; x++) { rowSpan[x] = Rgba32.FromRgba32(rgba32); } } }); // act using MemoryStream memStream = new(); image.Save(memStream, encoder); // assert memStream.Position = 0; using Image actual = Image.Load(memStream); Rgba32 expectedColor = Color.Blue.ToPixel(); actual.ProcessPixelRows(accessor => { Rgba32 transparent = Color.Transparent.ToPixel(); for (int y = 0; y < accessor.Height; y++) { Span rowSpan = accessor.GetRowSpan(y); if (y > 25) { expectedColor = transparent; } for (int x = 0; x < accessor.Width; x++) { Assert.Equal(expectedColor, rowSpan[x]); } } }); } [Theory] [WithFile(TestImages.Gif.Issues.Issue2866, PixelTypes.Rgba32)] public void GifEncoder_CanDecode_AndEncode_Issue2866(TestImageProvider provider) where TPixel : unmanaged, IPixel { using Image image = provider.GetImage(); // Save the image for visual inspection. provider.Utility.SaveTestOutputFile(image, "gif", new GifEncoder(), "animated"); // Now compare the debug output with the reference output. // We do this because the gif encoding is lossy and encoding will lead to differences in the 10s of percent. // From the unencoded image, we can see that the image is visually the same. static bool Predicate(int i, int _) => i % 8 == 0; // Image has many frames, only compare a selection of them. image.CompareDebugOutputToReferenceOutputMultiFrame(provider, ImageComparer.Exact, extension: "gif", predicate: Predicate); } }