diff --git a/Minint.Infrastructure/Export/BmpExporter.cs b/Minint.Infrastructure/Export/BmpExporter.cs
new file mode 100644
index 0000000..e0f49fd
--- /dev/null
+++ b/Minint.Infrastructure/Export/BmpExporter.cs
@@ -0,0 +1,80 @@
+using Minint.Core.Services;
+
+namespace Minint.Infrastructure.Export;
+
+///
+/// Writes a 32-bit BGRA BMP (BITMAPV4HEADER) from an ARGB pixel buffer.
+/// BMP rows are bottom-up, so we flip vertically during write.
+///
+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);
+ }
+ }
+ }
+}
diff --git a/Minint.Infrastructure/Export/GifExporter.cs b/Minint.Infrastructure/Export/GifExporter.cs
new file mode 100644
index 0000000..5eafc18
--- /dev/null
+++ b/Minint.Infrastructure/Export/GifExporter.cs
@@ -0,0 +1,329 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Minint.Core.Services;
+
+namespace Minint.Infrastructure.Export;
+
+///
+/// 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.
+///
+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();
+ 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 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
+
+ ///
+ /// 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.
+ ///
+ private static (byte[][] Palette, byte[] Indices, int TransparentIndex) Quantize(uint[] argb, int count)
+ {
+ bool hasTransparency = false;
+ var colorCounts = new Dictionary();
+
+ 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>(colorCounts);
+ sorted.Sort((a, b) => b.Value.CompareTo(a.Value));
+ if (sorted.Count > maxColors)
+ sorted.RemoveRange(maxColors, sorted.Count - maxColors);
+
+ var palette = new List();
+ var colorToIndex = new Dictionary();
+
+ 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 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
+}
diff --git a/Minint/ViewModels/EditorViewModel.cs b/Minint/ViewModels/EditorViewModel.cs
index af41da5..b0f1ca0 100644
--- a/Minint/ViewModels/EditorViewModel.cs
+++ b/Minint/ViewModels/EditorViewModel.cs
@@ -5,6 +5,7 @@ using System.ComponentModel;
using System.Linq;
using Avalonia;
using Avalonia.Media.Imaging;
+using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Minint.Core.Models;
@@ -341,6 +342,7 @@ public partial class EditorViewModel : ViewModelBase
public void OnToolDown(int px, int py)
{
+ if (IsPlaying) return;
if (Container is null || ActiveDocument is null || ActiveLayer is null)
return;
@@ -374,6 +376,7 @@ public partial class EditorViewModel : ViewModelBase
public void OnToolDrag(int px, int py)
{
+ if (IsPlaying) return;
if (ActiveTool is ToolType.Fill or ToolType.Select) return;
OnToolDown(px, py);
}
@@ -408,6 +411,7 @@ public partial class EditorViewModel : ViewModelBase
/// Called by PixelCanvas when selection drag starts.
public void BeginSelection(int px, int py)
{
+ if (IsPlaying) return;
SelectionRect = (px, py, 0, 0);
}
@@ -490,13 +494,14 @@ public partial class EditorViewModel : ViewModelBase
public void MovePaste(int px, int py)
{
- if (!IsPasting) return;
+ if (IsPlaying || !IsPasting) return;
PastePosition = (px, py);
}
[RelayCommand]
public void CommitPaste()
{
+ if (IsPlaying) return;
if (!IsPasting || Clipboard is null || ActiveDocument is null || ActiveLayer is null || Container is null)
return;
@@ -603,6 +608,125 @@ public partial class EditorViewModel : ViewModelBase
#endregion
+ #region Animation playback
+
+ private DispatcherTimer? _animationTimer;
+ private int _animationFrameIndex;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsNotPlaying))]
+ private bool _isPlaying;
+
+ public bool IsNotPlaying => !IsPlaying;
+
+ [RelayCommand]
+ private void PlayAnimation()
+ {
+ if (Container is null || Container.Documents.Count < 2) return;
+ if (IsPlaying) return;
+
+ IsPlaying = true;
+ _animationFrameIndex = ActiveDocument is not null
+ ? Container.Documents.IndexOf(ActiveDocument)
+ : 0;
+ if (_animationFrameIndex < 0) _animationFrameIndex = 0;
+
+ AdvanceAnimationFrame();
+ }
+
+ [RelayCommand]
+ private void StopAnimation()
+ {
+ _animationTimer?.Stop();
+ _animationTimer = null;
+ IsPlaying = false;
+ }
+
+ private void AdvanceAnimationFrame()
+ {
+ if (Container is null || !IsPlaying)
+ {
+ StopAnimation();
+ return;
+ }
+
+ var docs = Container.Documents;
+ if (docs.Count == 0)
+ {
+ StopAnimation();
+ return;
+ }
+
+ _animationFrameIndex %= docs.Count;
+ var doc = docs[_animationFrameIndex];
+
+ _suppressDocumentSync = true;
+ ActiveDocument = doc;
+ _suppressDocumentSync = false;
+ RefreshCanvasFor(doc);
+
+ uint delay = doc.FrameDelayMs;
+ if (delay < 10) delay = 10;
+
+ _animationTimer?.Stop();
+ _animationTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(delay) };
+ _animationTimer.Tick += (_, _) =>
+ {
+ _animationTimer?.Stop();
+ _animationFrameIndex++;
+ AdvanceAnimationFrame();
+ };
+ _animationTimer.Start();
+ }
+
+ private void RefreshCanvasFor(MinintDocument doc)
+ {
+ if (Container is null)
+ {
+ CanvasBitmap = null;
+ return;
+ }
+
+ int w = Container.Width;
+ int h = Container.Height;
+ uint[] argb = _compositor.Composite(doc, w, h);
+
+ var bmp = new WriteableBitmap(
+ new PixelSize(w, h),
+ new Vector(96, 96),
+ Avalonia.Platform.PixelFormat.Bgra8888);
+
+ using (var fb = bmp.Lock())
+ {
+ unsafe
+ {
+ var dst = new Span((void*)fb.Address, w * h);
+ for (int i = 0; i < argb.Length; i++)
+ {
+ uint px = argb[i];
+ byte a2 = (byte)(px >> 24);
+ byte r2 = (byte)((px >> 16) & 0xFF);
+ byte g2 = (byte)((px >> 8) & 0xFF);
+ byte b2 = (byte)(px & 0xFF);
+
+ if (a2 == 255) { dst[i] = px; }
+ else if (a2 == 0) { dst[i] = 0; }
+ else
+ {
+ r2 = (byte)(r2 * a2 / 255);
+ g2 = (byte)(g2 * a2 / 255);
+ b2 = (byte)(b2 * a2 / 255);
+ dst[i] = (uint)(b2 | (g2 << 8) | (r2 << 16) | (a2 << 24));
+ }
+ }
+ }
+ }
+
+ CanvasBitmap = bmp;
+ }
+
+ #endregion
+
public ICompositor Compositor => _compositor;
public IPaletteService PaletteService => _paletteService;
public IDrawingService DrawingService => _drawingService;
diff --git a/Minint/ViewModels/MainWindowViewModel.cs b/Minint/ViewModels/MainWindowViewModel.cs
index 2ce1dde..769933b 100644
--- a/Minint/ViewModels/MainWindowViewModel.cs
+++ b/Minint/ViewModels/MainWindowViewModel.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Controls;
@@ -8,6 +9,7 @@ using CommunityToolkit.Mvvm.Input;
using Minint.Core.Models;
using Minint.Core.Services;
using Minint.Core.Services.Impl;
+using Minint.Infrastructure.Export;
using Minint.Infrastructure.Serialization;
using Minint.Views;
@@ -18,12 +20,25 @@ public partial class MainWindowViewModel : ViewModelBase
private readonly MinintSerializer _serializer = new();
private readonly IImageEffectsService _effects = new ImageEffectsService();
private readonly IPatternGenerator _patternGen = new PatternGenerator();
+ private readonly IBmpExporter _bmpExporter = new BmpExporter();
+ private readonly IGifExporter _gifExporter = new GifExporter();
+ private readonly ICompositor _compositor = new Compositor();
private static readonly FilePickerFileType MinintFileType = new("Minint Files")
{
Patterns = ["*.minint"],
};
+ private static readonly FilePickerFileType BmpFileType = new("BMP Image")
+ {
+ Patterns = ["*.bmp"],
+ };
+
+ private static readonly FilePickerFileType GifFileType = new("GIF Animation")
+ {
+ Patterns = ["*.gif"],
+ };
+
[ObservableProperty]
private EditorViewModel _editor = new();
@@ -185,4 +200,80 @@ public partial class MainWindowViewModel : ViewModelBase
}
#endregion
+
+ #region Export (BMP / GIF)
+
+ [RelayCommand]
+ private async Task ExportBmpAsync()
+ {
+ if (Editor.Container is null || Editor.ActiveDocument is null) return;
+ if (Owner?.StorageProvider is not { } sp) return;
+
+ var file = await sp.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Export document as BMP",
+ DefaultExtension = "bmp",
+ FileTypeChoices = [BmpFileType],
+ SuggestedFileName = $"{Editor.ActiveDocument.Name}.bmp",
+ });
+ if (file is null) return;
+
+ var path = file.TryGetLocalPath();
+ if (path is null) { StatusText = "Error: could not resolve file path."; return; }
+
+ try
+ {
+ int w = Editor.Container.Width, h = Editor.Container.Height;
+ uint[] argb = _compositor.Composite(Editor.ActiveDocument, w, h);
+ await using var fs = File.Create(path);
+ _bmpExporter.Export(fs, argb, w, h);
+ StatusText = $"Exported BMP: {Path.GetFileName(path)}";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"BMP export failed: {ex.Message}";
+ }
+ }
+
+ [RelayCommand]
+ private async Task ExportGifAsync()
+ {
+ if (Editor.Container is null || Editor.Container.Documents.Count == 0) return;
+ if (Owner?.StorageProvider is not { } sp) return;
+
+ var file = await sp.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Export animation as GIF",
+ DefaultExtension = "gif",
+ FileTypeChoices = [GifFileType],
+ SuggestedFileName = Editor.FilePath is not null
+ ? Path.GetFileNameWithoutExtension(Editor.FilePath) + ".gif"
+ : "animation.gif",
+ });
+ if (file is null) return;
+
+ var path = file.TryGetLocalPath();
+ if (path is null) { StatusText = "Error: could not resolve file path."; return; }
+
+ try
+ {
+ int w = Editor.Container.Width, h = Editor.Container.Height;
+ var frames = new List<(uint[] Pixels, uint DelayMs)>();
+ foreach (var doc in Editor.Container.Documents)
+ {
+ uint[] argb = _compositor.Composite(doc, w, h);
+ frames.Add((argb, doc.FrameDelayMs));
+ }
+
+ await using var fs = File.Create(path);
+ _gifExporter.Export(fs, frames, w, h);
+ StatusText = $"Exported GIF ({frames.Count} frames): {Path.GetFileName(path)}";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"GIF export failed: {ex.Message}";
+ }
+ }
+
+ #endregion
}
diff --git a/Minint/Views/MainWindow.axaml b/Minint/Views/MainWindow.axaml
index 4de0f0b..9639e4d 100644
--- a/Minint/Views/MainWindow.axaml
+++ b/Minint/Views/MainWindow.axaml
@@ -25,6 +25,11 @@
+
+
+