Этап 10

This commit is contained in:
2026-03-29 18:19:39 +03:00
parent 415b1a41fc
commit 770dd629f5
5 changed files with 655 additions and 1 deletions

View File

@@ -0,0 +1,80 @@
using Minint.Core.Services;
namespace Minint.Infrastructure.Export;
/// <summary>
/// Writes a 32-bit BGRA BMP (BITMAPV4HEADER) from an ARGB pixel buffer.
/// BMP rows are bottom-up, so we flip vertically during write.
/// </summary>
public sealed class BmpExporter : IBmpExporter
{
private const int BmpFileHeaderSize = 14;
private const int BitmapV4HeaderSize = 108;
private const int HeadersTotal = BmpFileHeaderSize + BitmapV4HeaderSize;
public void Export(Stream stream, uint[] pixels, int width, int height)
{
ArgumentNullException.ThrowIfNull(stream);
ArgumentNullException.ThrowIfNull(pixels);
if (pixels.Length != width * height)
throw new ArgumentException("Pixel buffer size does not match dimensions.");
int rowBytes = width * 4;
int imageSize = rowBytes * height;
int fileSize = HeadersTotal + imageSize;
using var w = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true);
// BITMAPFILEHEADER (14 bytes)
w.Write((byte)'B');
w.Write((byte)'M');
w.Write(fileSize);
w.Write((ushort)0); // reserved1
w.Write((ushort)0); // reserved2
w.Write(HeadersTotal); // pixel data offset
// BITMAPV4HEADER (108 bytes)
w.Write(BitmapV4HeaderSize);
w.Write(width);
w.Write(height); // positive = bottom-up
w.Write((ushort)1); // planes
w.Write((ushort)32); // bpp
w.Write(3); // biCompression = BI_BITFIELDS
w.Write(imageSize);
w.Write(2835); // X pixels per meter (~72 DPI)
w.Write(2835); // Y pixels per meter
w.Write(0); // colors used
w.Write(0); // important colors
// Channel masks (BGRA order in file)
w.Write(0x00FF0000u); // red mask
w.Write(0x0000FF00u); // green mask
w.Write(0x000000FFu); // blue mask
w.Write(0xFF000000u); // alpha mask
// Color space type: LCS_sRGB
w.Write(0x73524742); // 'sRGB'
// CIEXYZTRIPLE endpoints (36 bytes zeroed)
w.Write(new byte[36]);
// Gamma RGB (12 bytes zeroed)
w.Write(new byte[12]);
// Pixel data: BMP is bottom-up, our buffer is top-down
for (int y = height - 1; y >= 0; y--)
{
int rowStart = y * width;
for (int x = 0; x < width; x++)
{
uint argb = pixels[rowStart + x];
byte a = (byte)(argb >> 24);
byte r = (byte)((argb >> 16) & 0xFF);
byte g = (byte)((argb >> 8) & 0xFF);
byte b = (byte)(argb & 0xFF);
w.Write(b);
w.Write(g);
w.Write(r);
w.Write(a);
}
}
}
}

View File

@@ -0,0 +1,329 @@
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 &lt; 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
}