330 lines
9.9 KiB
C#
330 lines
9.9 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using Minint.Core.Services;
|
|
|
|
namespace Minint.Infrastructure.Export;
|
|
|
|
/// <summary>
|
|
/// Self-implemented GIF89a animated exporter.
|
|
/// Quantizes each ARGB frame to 256 colors (including transparent) using popularity.
|
|
/// Uses LZW compression as required by the GIF spec.
|
|
/// </summary>
|
|
public sealed class GifExporter : IGifExporter
|
|
{
|
|
public void Export(Stream stream, IReadOnlyList<(uint[] Pixels, uint DelayMs)> frames, int width, int height)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(stream);
|
|
if (frames.Count == 0)
|
|
throw new ArgumentException("At least one frame is required.");
|
|
|
|
using var w = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true);
|
|
|
|
WriteGifHeader(w, width, height);
|
|
WriteNetscapeExtension(w); // infinite loop
|
|
|
|
foreach (var (pixels, delayMs) in frames)
|
|
{
|
|
var (palette, indices, transparentIndex) = Quantize(pixels, width * height);
|
|
int colorBits = GetColorBits(palette.Length);
|
|
int tableSize = 1 << colorBits;
|
|
|
|
WriteGraphicControlExtension(w, delayMs, transparentIndex);
|
|
WriteImageDescriptor(w, width, height, colorBits);
|
|
WriteColorTable(w, palette, tableSize);
|
|
WriteLzwImageData(w, indices, colorBits);
|
|
}
|
|
|
|
w.Write((byte)0x3B); // GIF trailer
|
|
}
|
|
|
|
private static void WriteGifHeader(BinaryWriter w, int width, int height)
|
|
{
|
|
w.Write("GIF89a"u8.ToArray());
|
|
w.Write((ushort)width);
|
|
w.Write((ushort)height);
|
|
// Global color table flag=0, color resolution=7, sort=0, gct size=0
|
|
w.Write((byte)0x70);
|
|
w.Write((byte)0); // background color index
|
|
w.Write((byte)0); // pixel aspect ratio
|
|
}
|
|
|
|
private static void WriteNetscapeExtension(BinaryWriter w)
|
|
{
|
|
w.Write((byte)0x21); // extension introducer
|
|
w.Write((byte)0xFF); // application extension
|
|
w.Write((byte)11); // block size
|
|
w.Write("NETSCAPE2.0"u8.ToArray());
|
|
w.Write((byte)3); // sub-block size
|
|
w.Write((byte)1); // sub-block ID
|
|
w.Write((ushort)0); // loop count: 0 = infinite
|
|
w.Write((byte)0); // block terminator
|
|
}
|
|
|
|
private static void WriteGraphicControlExtension(BinaryWriter w, uint delayMs, int transparentIndex)
|
|
{
|
|
w.Write((byte)0x21); // extension introducer
|
|
w.Write((byte)0xF9); // graphic control label
|
|
w.Write((byte)4); // block size
|
|
byte packed = (byte)(transparentIndex >= 0 ? 0x09 : 0x08);
|
|
// disposal method=2 (restore to background), no user input, transparent flag
|
|
w.Write(packed);
|
|
ushort delayCs = (ushort)(delayMs / 10); // GIF delay is in centiseconds
|
|
if (delayCs == 0 && delayMs > 0) delayCs = 1;
|
|
w.Write(delayCs);
|
|
w.Write((byte)(transparentIndex >= 0 ? transparentIndex : 0));
|
|
w.Write((byte)0); // block terminator
|
|
}
|
|
|
|
private static void WriteImageDescriptor(BinaryWriter w, int width, int height, int colorBits)
|
|
{
|
|
w.Write((byte)0x2C); // image separator
|
|
w.Write((ushort)0); // left
|
|
w.Write((ushort)0); // top
|
|
w.Write((ushort)width);
|
|
w.Write((ushort)height);
|
|
byte packed = (byte)(0x80 | (colorBits - 1)); // local color table, not interlaced
|
|
w.Write(packed);
|
|
}
|
|
|
|
private static void WriteColorTable(BinaryWriter w, byte[][] palette, int tableSize)
|
|
{
|
|
for (int i = 0; i < tableSize; i++)
|
|
{
|
|
if (i < palette.Length)
|
|
{
|
|
w.Write(palette[i][0]); // R
|
|
w.Write(palette[i][1]); // G
|
|
w.Write(palette[i][2]); // B
|
|
}
|
|
else
|
|
{
|
|
w.Write((byte)0);
|
|
w.Write((byte)0);
|
|
w.Write((byte)0);
|
|
}
|
|
}
|
|
}
|
|
|
|
#region LZW compression
|
|
|
|
private static void WriteLzwImageData(BinaryWriter w, byte[] indices, int colorBits)
|
|
{
|
|
int minCodeSize = Math.Max(colorBits, 2);
|
|
w.Write((byte)minCodeSize);
|
|
|
|
var output = new List<byte>();
|
|
LzwCompress(indices, minCodeSize, output);
|
|
|
|
int offset = 0;
|
|
while (offset < output.Count)
|
|
{
|
|
int blockLen = Math.Min(255, output.Count - offset);
|
|
w.Write((byte)blockLen);
|
|
for (int i = 0; i < blockLen; i++)
|
|
w.Write(output[offset + i]);
|
|
offset += blockLen;
|
|
}
|
|
w.Write((byte)0); // block terminator
|
|
}
|
|
|
|
private static void LzwCompress(byte[] indices, int minCodeSize, List<byte> output)
|
|
{
|
|
int clearCode = 1 << minCodeSize;
|
|
int eoiCode = clearCode + 1;
|
|
|
|
int codeSize = minCodeSize + 1;
|
|
int nextCode = eoiCode + 1;
|
|
int maxCode = (1 << codeSize) - 1;
|
|
|
|
var table = new Dictionary<(int Prefix, byte Suffix), int>();
|
|
|
|
int bitBuffer = 0;
|
|
int bitCount = 0;
|
|
|
|
void EmitCode(int code)
|
|
{
|
|
bitBuffer |= code << bitCount;
|
|
bitCount += codeSize;
|
|
while (bitCount >= 8)
|
|
{
|
|
output.Add((byte)(bitBuffer & 0xFF));
|
|
bitBuffer >>= 8;
|
|
bitCount -= 8;
|
|
}
|
|
}
|
|
|
|
void ResetTable()
|
|
{
|
|
table.Clear();
|
|
codeSize = minCodeSize + 1;
|
|
nextCode = eoiCode + 1;
|
|
maxCode = (1 << codeSize) - 1;
|
|
}
|
|
|
|
EmitCode(clearCode);
|
|
ResetTable();
|
|
|
|
if (indices.Length == 0)
|
|
{
|
|
EmitCode(eoiCode);
|
|
if (bitCount > 0) output.Add((byte)(bitBuffer & 0xFF));
|
|
return;
|
|
}
|
|
|
|
int prefix = indices[0];
|
|
|
|
for (int i = 1; i < indices.Length; i++)
|
|
{
|
|
byte suffix = indices[i];
|
|
var key = (prefix, suffix);
|
|
|
|
if (table.TryGetValue(key, out int existing))
|
|
{
|
|
prefix = existing;
|
|
}
|
|
else
|
|
{
|
|
EmitCode(prefix);
|
|
|
|
if (nextCode <= 4095)
|
|
{
|
|
table[key] = nextCode++;
|
|
if (nextCode > maxCode + 1 && codeSize < 12)
|
|
{
|
|
codeSize++;
|
|
maxCode = (1 << codeSize) - 1;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
EmitCode(clearCode);
|
|
ResetTable();
|
|
}
|
|
|
|
prefix = suffix;
|
|
}
|
|
}
|
|
|
|
EmitCode(prefix);
|
|
EmitCode(eoiCode);
|
|
if (bitCount > 0) output.Add((byte)(bitBuffer & 0xFF));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Quantization
|
|
|
|
/// <summary>
|
|
/// Quantizes ARGB pixels to max 256 palette entries.
|
|
/// Reserves index 0 for transparent if any pixel has alpha < 128.
|
|
/// Uses popularity-based selection for opaque colors.
|
|
/// </summary>
|
|
private static (byte[][] Palette, byte[] Indices, int TransparentIndex) Quantize(uint[] argb, int count)
|
|
{
|
|
bool hasTransparency = false;
|
|
var colorCounts = new Dictionary<uint, int>();
|
|
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
uint px = argb[i];
|
|
byte a = (byte)(px >> 24);
|
|
if (a < 128)
|
|
{
|
|
hasTransparency = true;
|
|
continue;
|
|
}
|
|
uint opaque = px | 0xFF000000u;
|
|
colorCounts.TryGetValue(opaque, out int c);
|
|
colorCounts[opaque] = c + 1;
|
|
}
|
|
|
|
int transparentIndex = hasTransparency ? 0 : -1;
|
|
int maxColors = hasTransparency ? 255 : 256;
|
|
|
|
var sorted = new List<KeyValuePair<uint, int>>(colorCounts);
|
|
sorted.Sort((a, b) => b.Value.CompareTo(a.Value));
|
|
if (sorted.Count > maxColors)
|
|
sorted.RemoveRange(maxColors, sorted.Count - maxColors);
|
|
|
|
var palette = new List<byte[]>();
|
|
var colorToIndex = new Dictionary<uint, byte>();
|
|
|
|
if (hasTransparency)
|
|
{
|
|
palette.Add([0, 0, 0]); // transparent slot
|
|
}
|
|
|
|
foreach (var kv in sorted)
|
|
{
|
|
uint px = kv.Key;
|
|
byte idx = (byte)palette.Count;
|
|
palette.Add([
|
|
(byte)((px >> 16) & 0xFF),
|
|
(byte)((px >> 8) & 0xFF),
|
|
(byte)(px & 0xFF)
|
|
]);
|
|
colorToIndex[px] = idx;
|
|
}
|
|
|
|
if (palette.Count == 0)
|
|
palette.Add([0, 0, 0]);
|
|
|
|
var indices = new byte[count];
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
uint px = argb[i];
|
|
byte a = (byte)(px >> 24);
|
|
if (a < 128)
|
|
{
|
|
indices[i] = (byte)(transparentIndex >= 0 ? transparentIndex : 0);
|
|
continue;
|
|
}
|
|
|
|
uint opaque = px | 0xFF000000u;
|
|
if (colorToIndex.TryGetValue(opaque, out byte idx))
|
|
{
|
|
indices[i] = idx;
|
|
}
|
|
else
|
|
{
|
|
indices[i] = FindClosest(opaque, palette, hasTransparency ? 1 : 0);
|
|
}
|
|
}
|
|
|
|
return (palette.ToArray(), indices, transparentIndex);
|
|
}
|
|
|
|
private static byte FindClosest(uint argb, List<byte[]> palette, int startIdx)
|
|
{
|
|
byte r = (byte)((argb >> 16) & 0xFF);
|
|
byte g = (byte)((argb >> 8) & 0xFF);
|
|
byte b = (byte)(argb & 0xFF);
|
|
|
|
int bestIdx = startIdx;
|
|
int bestDist = int.MaxValue;
|
|
for (int i = startIdx; i < palette.Count; i++)
|
|
{
|
|
int dr = r - palette[i][0];
|
|
int dg = g - palette[i][1];
|
|
int db = b - palette[i][2];
|
|
int dist = dr * dr + dg * dg + db * db;
|
|
if (dist < bestDist)
|
|
{
|
|
bestDist = dist;
|
|
bestIdx = i;
|
|
}
|
|
}
|
|
return (byte)bestIdx;
|
|
}
|
|
|
|
private static int GetColorBits(int paletteCount)
|
|
{
|
|
int bits = 2;
|
|
while ((1 << bits) < paletteCount) bits++;
|
|
return Math.Min(bits, 8);
|
|
}
|
|
|
|
#endregion
|
|
}
|