Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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==12.3.0"
"${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
Expand Down
154 changes: 131 additions & 23 deletions Drawing/GifCodec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -439,40 +460,128 @@ 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)
bits++;

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)));
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Drawing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 32 additions & 0 deletions Tests/Drawing/BmpGifTddTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,38 @@ 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<byte>()); });
}


[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);
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),
("sixty-frame-screen-capture", screen.Build())
})
{
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);
Expand Down
Loading
Loading