From 770dd629f5257baf1c87e7ce5ce55145e2c06b6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Sun, 29 Mar 2026 18:19:39 +0300 Subject: [PATCH] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=2010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Minint.Infrastructure/Export/BmpExporter.cs | 80 +++++ Minint.Infrastructure/Export/GifExporter.cs | 329 ++++++++++++++++++++ Minint/ViewModels/EditorViewModel.cs | 126 +++++++- Minint/ViewModels/MainWindowViewModel.cs | 91 ++++++ Minint/Views/MainWindow.axaml | 30 ++ 5 files changed, 655 insertions(+), 1 deletion(-) create mode 100644 Minint.Infrastructure/Export/BmpExporter.cs create mode 100644 Minint.Infrastructure/Export/GifExporter.cs 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 @@ + + + + + + +