Этап 10
This commit is contained in:
80
Minint.Infrastructure/Export/BmpExporter.cs
Normal file
80
Minint.Infrastructure/Export/BmpExporter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
329
Minint.Infrastructure/Export/GifExporter.cs
Normal file
329
Minint.Infrastructure/Export/GifExporter.cs
Normal 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 < 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
|
||||
}
|
||||
@@ -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
|
||||
/// <summary>Called by PixelCanvas when selection drag starts.</summary>
|
||||
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<uint>((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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
<Separator/>
|
||||
<MenuItem Header="_Save" Command="{Binding SaveFileCommand}" HotKey="Ctrl+S"/>
|
||||
<MenuItem Header="Save _As…" Command="{Binding SaveFileAsCommand}" HotKey="Ctrl+Shift+S"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Export Document as _BMP…" Command="{Binding ExportBmpCommand}"
|
||||
ToolTip.Tip="Export the active document as a 32-bit BMP file"/>
|
||||
<MenuItem Header="Export All as _GIF…" Command="{Binding ExportGifCommand}"
|
||||
ToolTip.Tip="Export all documents as an animated GIF"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Edit">
|
||||
<MenuItem Header="_Copy" Command="{Binding Editor.CopySelectionCommand}"
|
||||
@@ -104,6 +109,31 @@
|
||||
BorderThickness="0,0,1,0" Padding="4">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="Documents" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
|
||||
<!-- Animation controls -->
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="4" Margin="0,4,0,0">
|
||||
<Button Content="▶" ToolTip.Tip="Play animation"
|
||||
Command="{Binding Editor.PlayAnimationCommand}"
|
||||
IsEnabled="{Binding Editor.IsNotPlaying}" Padding="6,2"/>
|
||||
<Button Content="■" ToolTip.Tip="Stop animation"
|
||||
Command="{Binding Editor.StopAnimationCommand}"
|
||||
IsEnabled="{Binding Editor.IsPlaying}" Padding="6,2"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Frame delay -->
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="4" Margin="0,4,0,0">
|
||||
<TextBlock Text="Delay:" VerticalAlignment="Center" FontSize="12"/>
|
||||
<NumericUpDown Value="{Binding Editor.ActiveDocument.FrameDelayMs}"
|
||||
Minimum="10" Maximum="10000" Increment="10"
|
||||
FormatString="0"
|
||||
Width="110" FontSize="12"
|
||||
ToolTip.Tip="Frame delay in milliseconds"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource SystemControlForegroundBaseHighBrush}"/>
|
||||
<TextBlock Text="ms" VerticalAlignment="Center" FontSize="12"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Document management buttons -->
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="2" Margin="0,4,0,0">
|
||||
<Button Content="+" ToolTip.Tip="Add a new document (frame)"
|
||||
Command="{Binding Editor.AddDocumentCommand}" Padding="6,2"/>
|
||||
|
||||
Reference in New Issue
Block a user