From 3d164340fb32c06f8bee55d0175f997b483160f1 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 11 Oct 2026 02:20:08 +0300 Subject: [PATCH 1/3] TDD RED: independently test GIF delta rectangles, disposal, loop seams and compressed size --- Tests/Drawing/GifCodecTests.cs | 166 ++++++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 1 deletion(-) diff --git a/Tests/Drawing/GifCodecTests.cs b/Tests/Drawing/GifCodecTests.cs index 1276f2f3..0f91607a 100644 --- a/Tests/Drawing/GifCodecTests.cs +++ b/Tests/Drawing/GifCodecTests.cs @@ -303,7 +303,7 @@ public void Reencoded_MoreColorsThanATableHolds_StayCloseToTheSource() }); var frame = GifFile.Read(Reencode(gif)).Frames[1]; - var actual = frame.ToRgba(); + var actual = IndependentlyComposited(GifFile.Read(Reencode(gif)))[1]; // Two neighbors of a ramp may share a table entry; nothing moves further than that. AssertPixels(expected, actual, width, tolerance: 6); @@ -337,6 +337,170 @@ public void Resized_Frames_AreTheAveragedSourcePixels() AssertPixels(Rgba(2, 2, (x, _) => x == 0 ? blue : _red), file.Frames[1].ToRgba(), 2); } + + [TestMethod] + public void DifferenceFrames_SixtySparseUpdates_AreSubrectanglesAndMuchSmaller() + { + const int width = 200, height = 120; + var builder = new GifBuilder(width, height, _palette) + .Loop(0) + .Frame(0, 0, width, height, Filled(width * height, 3), delay: 5); + + for (var i = 0; i < 60; i++) + builder.Frame(i * 3, i % 35, 1, 1, [1], delay: 2 + i % 7); + + var original = builder.Build(); + var encoded = Reencode(original); + var independent = GifFile.Read(encoded); + + independent.Frames.Count.AssertEqual(61); + independent.LoopCount.AssertEqual(0); + (encoded.Length < 15_000).AssertTrue($"Sparse animation unexpectedly takes {encoded.Length} bytes."); + + for (var i = 1; i < 61; i++) + { + var frame = independent.Frames[i]; + frame.Width.AssertEqual(1); + frame.Height.AssertEqual(1); + frame.Left.AssertEqual((i - 1) * 3); + frame.Top.AssertEqual((i - 1) % 35); + frame.Disposal.AssertEqual(1); + frame.Delay.AssertEqual(2 + (i - 1) % 7); + frame.HasEndCode.AssertTrue(); + } + AssertEveryFrameLikeSource(GifFile.Read(original), independent); + } + + [TestMethod] + public void DifferenceFrames_UnchangedHolesInBoundingRectangle_UseTransparentPaletteIndex() + { + var gif = new GifBuilder(7, 4, _palette) + .Frame(0, 0, 7, 4, Filled(28, 3), delay: 2) + .Frame(1, 1, 5, 2, + [ + 1, 0, 0, 0, 0, + 0, 0, 0, 0, 2 + ], transparent: 0, delay: 9) + .Build(); + + var output = GifFile.Read(Reencode(gif)); + var changed = output.Frames[1]; + + changed.Left.AssertEqual(1); + changed.Top.AssertEqual(1); + changed.Width.AssertEqual(5); + changed.Height.AssertEqual(2); + (changed.Transparent >= 0).AssertTrue(); + (changed.Indices[0] != changed.Transparent).AssertTrue(); + (changed.Indices[9] != changed.Transparent).AssertTrue(); + for (var index = 1; index < 9; index++) + changed.Indices[index].AssertEqual((byte)changed.Transparent); + changed.Disposal.AssertEqual(1); + + AssertEveryFrameLikeSource(GifFile.Read(gif), output); + } + + [TestMethod] + public void DifferenceFrames_OpaqueToTransparent_ClearsTheFullCanvas() + { + var gif = new GifBuilder(5, 3, _palette) + .Frame(0, 0, 5, 3, Filled(15, 1), disposal: 2, delay: 4) + .Frame(2, 1, 1, 1, [3], delay: 7) + .Build(); + + var output = GifFile.Read(Reencode(gif)); + output.Frames[0].Disposal.AssertEqual(2); + output.Frames[0].Width.AssertEqual(5); + output.Frames[0].Height.AssertEqual(3); + output.Frames[1].Left.AssertEqual(2); + output.Frames[1].Top.AssertEqual(1); + output.Frames[1].Width.AssertEqual(1); + output.Frames[1].Height.AssertEqual(1); + AssertEveryFrameLikeSource(GifFile.Read(gif), output); + } + + [TestMethod] + public void DifferenceFrames_UnchangedFrame_WritesOnlyOneTransparentPixel() + { + var gif = new GifBuilder(4, 3, _palette) + .Frame(0, 0, 4, 3, Filled(12, 1), delay: 3) + .Frame(0, 0, 4, 3, Filled(12, 1), delay: 11) + .Build(); + + var encoded = GifFile.Read(Reencode(gif)); + var duplicate = encoded.Frames[1]; + + duplicate.Width.AssertEqual(1); + duplicate.Height.AssertEqual(1); + (duplicate.Transparent >= 0).AssertTrue(); + duplicate.Indices.Single().AssertEqual((byte)duplicate.Transparent); + duplicate.Delay.AssertEqual(11); + AssertEveryFrameLikeSource(GifFile.Read(gif), encoded); + } + + [TestMethod] + public void DifferenceFrames_LastFrameAtLoopBoundary_ClearsBeforeTransparentFirstFrame() + { + var gif = new GifBuilder(3, 1, _palette) + .Loop(0) + .Frame(0, 0, 3, 1, [0, 1, 0], transparent: 0, delay: 2) + .Frame(0, 0, 3, 1, [1, 1, 1], delay: 5) + .Build(); + + var encoded = GifFile.Read(Reencode(gif)); + encoded.Frames[1].Disposal.AssertEqual(2); + encoded.Frames[1].Width.AssertEqual(3); + encoded.Frames[1].Height.AssertEqual(1); + encoded.LoopCount.AssertEqual(0); + AssertEveryFrameLikeSource(GifFile.Read(gif), encoded); + } + + private static void AssertEveryFrameLikeSource(GifFile original, GifFile encoded) + { + var a = IndependentlyComposited(original); + var b = IndependentlyComposited(encoded); + a.Length.AssertEqual(b.Length); + for (var i = 0; i < a.Length; i++) + AssertPixels(a[i], b[i], original.Width); + } + + // Test-only independent LZW decoding (GifFile) and disposal composition. + // Never uses the production GIF decoder to read encoded output. + private static byte[][] IndependentlyComposited(GifFile file) + { + var canvas = new byte[file.Width * file.Height * 4]; + var pictures = new List(); + GifFile.Frame previous = null; + byte[] previousSaved = null; + foreach (var frame in file.Frames) + { + if (previous?.Disposal == 2) + { + for (var y = previous.Top; y < previous.Top + previous.Height; y++) + for (var x = previous.Left; x < previous.Left + previous.Width; x++) + Array.Clear(canvas, (y * file.Width + x) * 4, 4); + } + else if (previous?.Disposal == 3) + previousSaved.CopyTo(canvas, 0); + + var saved = frame.Disposal == 3 ? (byte[])canvas.Clone() : null; + var patch = frame.ToRgba(); + for (var y = 0; y < frame.Height; y++) + for (var x = 0; x < frame.Width; x++) + { + var pixel = (y * frame.Width + x) * 4; + if (patch[pixel + 3] < 128) continue; + var dest = ((y + frame.Top) * file.Width + x + frame.Left) * 4; + Array.Copy(patch, pixel, canvas, dest, 4); + } + + pictures.Add((byte[])canvas.Clone()); + previous = frame; + previousSaved = saved; + } + return [.. pictures]; + } + // Sixteen one-pixel frames on a 3000x3000 screen. private static byte[] Huge(int loop) { From 2bed7273538355f5c9938a764772521834a96e68 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 11 Oct 2026 02:21:40 +0300 Subject: [PATCH 2/3] Encode GIF with changed-pixel rectangles and disposal-aware transparent deltas; verify against Pillow --- .github/workflows/dotnet.yml | 13 +++ Drawing/GifCodec.cs | 154 ++++++++++++++++++++++++----- Tests/Drawing/BmpGifTddTests.cs | 23 +++++ Tests/Drawing/verify_gif_pillow.py | 60 +++++++++++ 4 files changed, 227 insertions(+), 23 deletions(-) create mode 100644 Tests/Drawing/verify_gif_pillow.py diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index faa31d88..04804cf0 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -47,6 +47,19 @@ jobs: NUGET_PRIVATE_PACKAGE_VERSION: ${{ secrets.NUGET_PRIVATE_PACKAGE_VERSION }} run: dotnet test Ecng.slnx --no-build --configuration Release --settings Tests/test.runsettings --filter "FullyQualifiedName!~Azure&TestCategory!=Integration" + - name: Cross-check delta GIF frames using independent Pillow decoder + if: matrix.os == 'ubuntu-latest' + shell: bash + env: + ECNG_GIF_PILLOW_DIR: ${{ runner.temp }}/ecng-gif-pillow + run: | + mkdir -p "$ECNG_GIF_PILLOW_DIR" + dotnet test Tests/Tests.csproj -f net10.0 --no-build --configuration Release \ + --filter "FullyQualifiedName~Gif_DeltaFrames_ExportForPillowCrossValidation" + python3 -m venv "${RUNNER_TEMP}/gif-pillow-venv" + "${RUNNER_TEMP}/gif-pillow-venv/bin/python" -m pip install "Pillow>=11,<13" + "${RUNNER_TEMP}/gif-pillow-venv/bin/python" Tests/Drawing/verify_gif_pillow.py "$ECNG_GIF_PILLOW_DIR" + database: name: Database Integration runs-on: ubuntu-latest diff --git a/Drawing/GifCodec.cs b/Drawing/GifCodec.cs index 026eec79..4673f7ce 100644 --- a/Drawing/GifCodec.cs +++ b/Drawing/GifCodec.cs @@ -325,19 +325,40 @@ public static byte[] Encode(Animation animation) output.WriteByte(0); } - var count = 0; + // Look only one frame ahead. Frames may be decoded lazily, so do not + // materialize an entire screen recording in memory merely to encode it. + using var frames = animation.Frames.GetEnumerator(); - foreach (var frame in animation.Frames) + if (!frames.MoveNext()) + throw new InvalidDataException("Cannot encode an empty GIF."); + + var first = frames.Current; + var current = first; + RasterImage retained = null; + + while (true) { - if (frame.Image.Width != animation.Width || frame.Image.Height != animation.Height) + if (current.Image.Width != animation.Width || current.Image.Height != animation.Height) throw new InvalidDataException("GIF frame size must equal logical canvas."); - WriteFrame(output, frame); - count++; - } + var hasNext = frames.MoveNext(); + var next = hasNext ? frames.Current.Image : + animation.Plays != 1 ? first.Image : null; - if (count == 0) - throw new InvalidDataException("Cannot encode an empty GIF."); + // A transparent pixel cannot erase an opaque old GIF pixel. If the + // NEXT frame needs such erasure, encode this one full-screen and + // dispose it to transparent before the next frame is drawn. + // Include the last-to-first transition for looping animations. + var clearAfter = next is not null && NeedsClearing(current.Image, next); + WriteFrame(output, current, retained, clearAfter); + + retained = clearAfter ? null : current.Image; + + if (!hasNext) + break; + + current = frames.Current; + } output.WriteByte(_trailer); return output.ToArray(); @@ -439,23 +460,112 @@ private static void Draw(byte[] data, RasterImage canvas, Image image) } } - private static void WriteFrame(Stream output, AnimationFrame frame) + + // GIF uses binary transparency: the RGB of an invisible pixel does not + // matter, nor does the alpha value of an otherwise identical opaque pixel. + private static bool Matches(byte[] pixels, byte[] retained, int at) + { + var opaque = pixels[at + 3] >= ColorQuantizer.OpaqueAlpha; + var wasOpaque = retained is not null && retained[at + 3] >= ColorQuantizer.OpaqueAlpha; + + return opaque == wasOpaque && (!opaque || + (pixels[at] == retained[at] && + pixels[at + 1] == retained[at + 1] && + pixels[at + 2] == retained[at + 2])); + } + + private static bool NeedsClearing(RasterImage current, RasterImage next) { - var image = frame.Image; - var pixels = image.Width * image.Height; - var indices = new byte[pixels]; + var a = current.Pixels; + var b = next.Pixels; + for (var at = 3; at < a.Length; at += 4) + if (a[at] >= ColorQuantizer.OpaqueAlpha && b[at] < ColorQuantizer.OpaqueAlpha) + return true; + + return false; + } + + private static void WriteFrame(Stream output, AnimationFrame frame, RasterImage retained, bool clearAfter) + { + var full = frame.Image; + var source = full.Pixels; + var prior = retained?.Pixels; + var left = 0; + var top = 0; + var width = full.Width; + var height = full.Height; + RasterImage image; + + if (clearAfter) + { + // Disposal 2 must clear the complete canvas. Otherwise old opaque + // pixels outside a small frame rectangle could survive the clear. + image = full; + } + else + { + // Bounding rectangle of all differences from the canvas retained + // after the preceding frame's disposal. The entire-screen scan + // does not allocate a new full-canvas pixel buffer. + var minX = full.Width; + var minY = full.Height; + var maxX = -1; + var maxY = -1; + + for (var y = 0; y < full.Height; y++) + for (var x = 0; x < full.Width; x++) + { + var at = (y * full.Width + x) * 4; + if (Matches(source, prior, at)) + continue; + + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x); + maxY = Math.Max(maxY, y); + } + + if (maxX < 0) + { + // A frame with no changes is still required to carry its own + // delay. One transparent pixel leaves the canvas untouched. + image = new RasterImage(1, 1); + } + else + { + left = minX; + top = minY; + width = maxX - minX + 1; + height = maxY - minY + 1; + image = new RasterImage(width, height); + + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + { + var src = ((y + top) * full.Width + x + left) * 4; + if (Matches(source, prior, src)) + continue; // Transparent hole: show retained canvas. + + if (source[src + 3] < ColorQuantizer.OpaqueAlpha && + prior is not null && prior[src + 3] >= ColorQuantizer.OpaqueAlpha) + throw new InvalidDataException("GIF delta cannot remove opaque pixels without disposal."); + + Array.Copy(source, src, image.Pixels, (y * width + x) * 4, 4); + } + } + } + + var pixelCount = image.Width * image.Height; + var indices = new byte[pixelCount]; var hasTransparency = false; - for (var i = 0; i < pixels && !hasTransparency; i++) + for (var i = 0; i < pixelCount && !hasTransparency; i++) hasTransparency = image.Pixels[i * 4 + 3] < ColorQuantizer.OpaqueAlpha; - // A frame with transparent pixels gives them index 0 and has one color fewer for the rest. var first = hasTransparency ? 1 : 0; var colors = ColorQuantizer.Quantize(image, 256 - first, indices, first); var used = first + colors.Length / 3; - - // The table holds a power of two of colors, two at the least. var bits = 1; while (1 << bits < used) @@ -463,16 +573,15 @@ private static void WriteFrame(Stream output, AnimationFrame frame) var delay = Math.Clamp((int)Math.Round(frame.DelayNumerator * 100.0 / frame.DelayDenominator, MidpointRounding.AwayFromZero), 0, ushort.MaxValue); - // Every frame is the whole canvas and is cleared before the next one, so that the transparent - // pixels of a frame show nothing of the frame before it. - output.Write([_extension, _graphicControl, 4, (byte)(_disposeToBackground << 2 | (hasTransparency ? 1 : 0))]); + output.Write([_extension, _graphicControl, 4, + (byte)((clearAfter ? _disposeToBackground : 1) << 2 | (hasTransparency ? 1 : 0))]); Word(output, delay); - output.WriteByte(0); + output.WriteByte(0); // Transparent index, if any. output.WriteByte(0); output.WriteByte(_imageSeparator); - Word(output, 0); - Word(output, 0); + Word(output, left); + Word(output, top); Word(output, image.Width); Word(output, image.Height); output.WriteByte((byte)(0x80 | (bits - 1))); @@ -483,7 +592,6 @@ private static void WriteFrame(Stream output, AnimationFrame frame) var minCodeSize = Math.Max(2, bits); output.WriteByte((byte)minCodeSize); - var compressed = Compress(indices, minCodeSize); for (var at = 0; at < compressed.Length; at += 255) diff --git a/Tests/Drawing/BmpGifTddTests.cs b/Tests/Drawing/BmpGifTddTests.cs index 8523b392..0d312cd1 100644 --- a/Tests/Drawing/BmpGifTddTests.cs +++ b/Tests/Drawing/BmpGifTddTests.cs @@ -351,6 +351,29 @@ void Reject(Action<(string tag, byte[] data)[]> change) Reject(chunks => { var at = Array.FindIndex(chunks, c => c.tag == "fdAT"); chunks[at] = (chunks[at].tag, Array.Empty()); }); } + + [TestMethod] + public void Gif_DeltaFrames_ExportForPillowCrossValidation() + { + // Independent Python/Pillow CI step checks every displayed pixel, + // frame duration and loop count on the original and encoded GIFs. + var directory = Environment.GetEnvironmentVariable("ECNG_GIF_PILLOW_DIR"); + if (string.IsNullOrEmpty(directory)) + return; + + Directory.CreateDirectory(directory); + foreach (var (name, original) in new[] + { + ("four-frame-disposals", _animated), + ("three-frame-large", _watermarkGif) + }) + { + File.WriteAllBytes(Path.Combine(directory, name + ".original.gif"), original); + File.WriteAllBytes(Path.Combine(directory, name + ".delta.gif"), + GifCodec.Encode(GifCodec.Open(original))); + } + } + private static void CompareNonEqual(byte[] expected, byte[] actual) { expected.Length.AssertEqual(actual.Length); diff --git a/Tests/Drawing/verify_gif_pillow.py b/Tests/Drawing/verify_gif_pillow.py new file mode 100644 index 00000000..a4da73b3 --- /dev/null +++ b/Tests/Drawing/verify_gif_pillow.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Validate re-encoded GIF animations with independent Pillow GIF decoding. + +The input and output GIFs are exported by BmpGifTddTests in CI. Pillow is +strictly a test-only dependency. Both animations must have identical visible +RGBA pixels, frame delays, frame count, loop metadata and canvas size. +""" +from pathlib import Path +import sys +from PIL import Image, ImageSequence + + +def render(file: Path): + with Image.open(file) as gif: + geometry = gif.size + loops = gif.info.get("loop", -1) + frames = [] + for frame in ImageSequence.Iterator(gif): + rgba = bytearray(frame.convert("RGBA").tobytes()) + # GIF transparent palette RGB is not visible; normalize it. + for at in range(0, len(rgba), 4): + if rgba[at + 3] == 0: + rgba[at:at + 3] = b"\x00\x00\x00" + frames.append((frame.info.get("duration", 0), bytes(rgba))) + return geometry, loops, frames + + +def main(directory): + folder = Path(directory) + sources = sorted(folder.glob("*.original.gif")) + if len(sources) < 2: + raise AssertionError(f"Expected at least two GIF test animations in {folder}") + + for source in sources: + output = folder / source.name.replace(".original.gif", ".delta.gif") + if not output.is_file(): + raise AssertionError(f"Missing compressed GIF: {output}") + a = render(source) + b = render(output) + if a[0] != b[0] or a[1] != b[1] or len(a[2]) != len(b[2]): + raise AssertionError(f"GIF metadata mismatch in {source.name}: {a[:2]} vs {b[:2]}") + + for index, ((delay_a, pixels_a), (delay_b, pixels_b)) in enumerate(zip(a[2], b[2])): + if delay_a != delay_b: + raise AssertionError( + f"{source.name} frame {index}: delay {delay_a} != {delay_b}") + if pixels_a != pixels_b: + differing = next(i for i, (x, y) in enumerate(zip(pixels_a, pixels_b)) if x != y) + raise AssertionError( + f"{source.name} frame {index}: RGBA channel {differing} differs " + f"at pixel {differing // 4}: {pixels_a[differing]} != {pixels_b[differing]}") + print(f"PASS {source.name}: {len(a[2])} frames, " + f"{source.stat().st_size:,} -> {output.stat().st_size:,} bytes, " + f"{a[0][0]}x{a[0][1]}, loops={a[1]}") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit("usage: verify_gif_pillow.py ") + main(sys.argv[1]) From c24273e94b992a93804a34826bc8cdb5daf55982 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 11 Oct 2026 02:24:58 +0300 Subject: [PATCH 3/3] Document disposal-aware GIF deltas and extend Pillow verification to 60-frame recordings --- .github/workflows/dotnet.yml | 2 +- Drawing/README.md | 2 +- Tests/Drawing/BmpGifTddTests.cs | 11 ++++++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 04804cf0..849ebff8 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -57,7 +57,7 @@ jobs: dotnet test Tests/Tests.csproj -f net10.0 --no-build --configuration Release \ --filter "FullyQualifiedName~Gif_DeltaFrames_ExportForPillowCrossValidation" python3 -m venv "${RUNNER_TEMP}/gif-pillow-venv" - "${RUNNER_TEMP}/gif-pillow-venv/bin/python" -m pip install "Pillow>=11,<13" + "${RUNNER_TEMP}/gif-pillow-venv/bin/python" -m pip install "Pillow==12.3.0" "${RUNNER_TEMP}/gif-pillow-venv/bin/python" Tests/Drawing/verify_gif_pillow.py "$ECNG_GIF_PILLOW_DIR" database: diff --git a/Drawing/README.md b/Drawing/README.md index cb07fec4..58d16e4d 100644 --- a/Drawing/README.md +++ b/Drawing/README.md @@ -315,7 +315,7 @@ GIF processing preserves **every displayed frame**, its delay and its looping, a - `ConvertToPng` on GIF yields **APNG**, not a frozen first frame. APNG carries the complete animation, timings and the same number of plays. Calling the other methods on that APNG is also animation-preserving. - APNG decoding handles per-frame rectangles, SOURCE/OVER blending, and NONE/BACKGROUND/PREVIOUS disposal. -The palette of a GIF frame holds at most **256 indexed colors**, and its transparency is **binary**. When re-encoding GIF, a frame keeps its exact colors if it has no more than 256 of them (255 beside transparent pixels); a frame with more is reduced to that many by median cut, without dithering, and alpha below 128 is made transparent. Every frame is written as the whole canvas with a color table of its own and LZW compression, so a GIF whose frames were stored as small differences comes out larger than it went in. APNG uses full lossless RGBA8; converting GIF to APNG does not introduce new quantization. +The palette of a GIF frame holds at most **256 indexed colors**, and its transparency is **binary**. When re-encoding GIF, a frame keeps its exact colors if it has no more than 256 of them (255 beside transparent pixels); a frame with more is reduced to that many by median cut, without dithering, and alpha below 128 is made transparent. When re-encoding GIF, unchanged pixels are not stored again: each frame normally uses the smallest rectangle of changed pixels, with a transparent index for unchanged pixels inside that rectangle and disposal method 1 (leave the underlying canvas in place). If the next frame needs formerly opaque pixels to become transparent, the preceding frame is written full-screen with disposal method 2 (restore transparent background). This also accounts for the last-to-first transition of a looping animation. Identical consecutive frames emit a single transparent pixel to preserve the frame delay. Every rectangle has its own color table and LZW compression. APNG uses full lossless RGBA8; converting GIF to APNG does not introduce new quantization. #### BMP diff --git a/Tests/Drawing/BmpGifTddTests.cs b/Tests/Drawing/BmpGifTddTests.cs index 0d312cd1..18ef85b2 100644 --- a/Tests/Drawing/BmpGifTddTests.cs +++ b/Tests/Drawing/BmpGifTddTests.cs @@ -362,10 +362,19 @@ public void Gif_DeltaFrames_ExportForPillowCrossValidation() return; Directory.CreateDirectory(directory); + const int width = 200, height = 120; + var screen = new GifBuilder(width, height, GifBuilder.Palette((0, 0, 0), (255, 0, 0))) + .Loop(0) + .Frame(0, 0, width, height, new byte[width * height], delay: 4); + + for (var index = 0; index < 60; index++) + screen.Frame(index * 3, index % 35, 1, 1, [1], delay: 2 + index % 7); + foreach (var (name, original) in new[] { ("four-frame-disposals", _animated), - ("three-frame-large", _watermarkGif) + ("three-frame-large", _watermarkGif), + ("sixty-frame-screen-capture", screen.Build()) }) { File.WriteAllBytes(Path.Combine(directory, name + ".original.gif"), original);