diff --git a/Directory.Packages.props b/Directory.Packages.props index bb225235..7f8b7b28 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,7 +16,6 @@ - diff --git a/Knossos.NET/Assets/general/knossos-1.gif b/Knossos.NET/Assets/general/knossos-1.gif deleted file mode 100644 index 99c878a1..00000000 Binary files a/Knossos.NET/Assets/general/knossos-1.gif and /dev/null differ diff --git a/Knossos.NET/Assets/general/knossos-2.gif b/Knossos.NET/Assets/general/knossos-2.gif deleted file mode 100644 index 3cbc62ec..00000000 Binary files a/Knossos.NET/Assets/general/knossos-2.gif and /dev/null differ diff --git a/Knossos.NET/Assets/general/knossos.apng b/Knossos.NET/Assets/general/knossos.apng new file mode 100644 index 00000000..2685d016 Binary files /dev/null and b/Knossos.NET/Assets/general/knossos.apng differ diff --git a/Knossos.NET/Classes/APNGHelper.cs b/Knossos.NET/Classes/APNGHelper.cs index ebdf43b1..089b0f5e 100644 --- a/Knossos.NET/Classes/APNGHelper.cs +++ b/Knossos.NET/Classes/APNGHelper.cs @@ -1,84 +1,605 @@ -using System; +using Avalonia; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using System; +using System.Collections.Generic; using System.IO; +using System.IO.Compression; +using System.Runtime.InteropServices; using System.Text; namespace Knossos.NET.Classes { /// - /// Helper class to read APNG files. + /// Parses APNG files and exposes a streaming composer that renders frames + /// on demand — no pre-composed pixel arrays are kept in memory. + /// + /// RAM cost at runtime: + /// Raw compressed IDAT/fdAT bytes for all frames (~same as file size) + /// One canvas buffer: W × H × 4 bytes (single live copy, reused every frame) + /// One extra canvas snapshot only while a DISPOSE_PREVIOUS frame is active + /// + /// Usage: + /// var apng = APNGHelper.ReadApng(_previewStream!); + /// if (apng != null && apng.Frames.Count > 0) + /// { + /// var composer = apng.CreateComposer(); + /// var localCts = _cts; + /// Task.Factory.StartNew(async () => + /// { + /// do + /// { + /// if (localCts?.IsCancellationRequested == true) break; + /// var bmp = composer.NextFrame(out int delayMs); + /// var old = ImageSource; + /// ImageSource = bmp; + /// await Task.Delay(delayMs); + /// old?.Dispose(); + /// } while (true); + /// composer.Dispose(); + /// }); + /// } /// public static class APNGHelper { - // PNG signature - private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; + private static readonly byte[] PngSignature = + { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; + + private const byte DisposeOpNone = 0; + private const byte DisposeOpBackground = 1; + private const byte DisposeOpPrevious = 2; + private const byte BlendOpSource = 0; + private const byte BlendOpOver = 1; + + /// + /// One APNG frame: only the raw compressed bytes and metadata. + /// Pixels are decoded on demand in . + /// + public sealed class ApngRawFrame + { + public int Width, Height, X, Y; + public int DelayNum, DelayDen; + public byte DisposeOp, BlendOp; + public byte[] CompressedData = Array.Empty(); // merged zlib IDAT payload + + public int DelayMs + { + get + { + int den = DelayDen == 0 ? 100 : DelayDen; + return Math.Max(1, (int)Math.Round(DelayNum * 1000.0 / den)); + } + } + } + + /// + /// Parsed APNG: canvas metadata + list of raw (compressed) frames. + /// Pixel decoding is deferred until is used. + /// + public sealed class ApngFile + { + public int CanvasWidth { get; init; } + public int CanvasHeight { get; init; } + public int NumPlays { get; init; } // 0 = loop forever + public byte ColorType { get; init; } + public (byte R, byte G, byte B)? TransparentRgb { get; init; } + public List Frames { get; init; } = new(); + + /// + /// Creates a new independent composer for this APNG. + /// Each composer has its own canvas; call this once per animation instance. + /// + public ApngComposer CreateComposer() => new ApngComposer(this); + } + + /// + /// Stateful on-the-fly APNG composer. + /// Maintains one canvas buffer and decodes+composites frames one at a time. + /// Call each tick; dispose the returned bitmap after display. + /// + public sealed class ApngComposer : IDisposable + { + private readonly ApngFile _apng; + private readonly byte[] _canvas; // RGBA8888, single live copy + private byte[]? _prevCanvas; // only alive during DISPOSE_PREVIOUS frames + private int _frameIndex; + private bool _disposed; + + internal ApngComposer(ApngFile apng) + { + _apng = apng; + _canvas = new byte[apng.CanvasWidth * apng.CanvasHeight * 4]; + } + + /// Total number of frames in the animation. + public int FrameCount => _apng.Frames.Count; + + /// Current frame index (0-based). + public int FrameIndex => _frameIndex; + + /// Canvas width. + public int CanvasWidth => _apng.CanvasWidth; + + /// Canvas height. + public int CanvasHeight => _apng.CanvasHeight; + + /// + /// Composes and returns the next animation frame as a WriteableBitmap. + /// Automatically wraps around to frame 0 after the last frame. + /// The CALLER must dispose the returned bitmap after displaying it. + /// + /// Display duration for this frame in milliseconds. + public WriteableBitmap NextFrame(out int delayMs) + { + if (_disposed) throw new ObjectDisposedException(nameof(ApngComposer)); + + var raw = _apng.Frames[_frameIndex]; + delayMs = raw.DelayMs; + + // Snapshot canvas before compositing if we'll need to restore it afterward + if (raw.DisposeOp == DisposeOpPrevious) + _prevCanvas = (byte[])_canvas.Clone(); + + // Decode this frame's compressed pixels → RGBA8888 + byte[] frameRgba = DecodeFrame(raw, _apng.ColorType, _apng.TransparentRgb); + + // Composite onto the persistent canvas + CompositeFrame(_canvas, _apng.CanvasWidth, frameRgba, raw); + + // Wrap canvas pixels into a WriteableBitmap for display + var bmp = CanvasToWriteableBitmap(_canvas, _apng.CanvasWidth, _apng.CanvasHeight); + + AdvanceCanvasState(raw); + _frameIndex = (_frameIndex + 1) % _apng.Frames.Count; + return bmp; + } + + // ───────────────────────────────────────────────────────────────────── + // render directly into an existing WriteableBitmap. + // This avoids allocating a new WriteableBitmap (and its W*H*4 buffer) + // on every frame. The target MUST be sized exactly + // CanvasWidth x CanvasHeight and use PixelFormat.Rgba8888 / + // AlphaFormat.Unpremul. After calling this, invalidate the visual + // that displays the bitmap so the new pixels are repainted. + // ───────────────────────────────────────────────────────────────────── + public void RenderNextFrameTo(WriteableBitmap target, out int delayMs) + { + if (_disposed) throw new ObjectDisposedException(nameof(ApngComposer)); + if (target == null) throw new ArgumentNullException(nameof(target)); + if (target.PixelSize.Width != _apng.CanvasWidth || + target.PixelSize.Height != _apng.CanvasHeight) + { + throw new ArgumentException( + $"Target size {target.PixelSize} doesn't match canvas " + + $"({_apng.CanvasWidth}x{_apng.CanvasHeight}).", nameof(target)); + } + + var raw = _apng.Frames[_frameIndex]; + delayMs = raw.DelayMs; + + if (raw.DisposeOp == DisposeOpPrevious) + _prevCanvas = (byte[])_canvas.Clone(); + + byte[] frameRgba = DecodeFrame(raw, _apng.ColorType, _apng.TransparentRgb); + CompositeFrame(_canvas, _apng.CanvasWidth, frameRgba, raw); + + // Copy the canvas straight into the existing locked framebuffer. + using (var fb = target.Lock()) + Marshal.Copy(_canvas, 0, fb.Address, _canvas.Length); + + AdvanceCanvasState(raw); + _frameIndex = (_frameIndex + 1) % _apng.Frames.Count; + } + + /// Resets the animation back to the first frame and clears the canvas. + public void Reset() + { + if (_disposed) throw new ObjectDisposedException(nameof(ApngComposer)); + Array.Clear(_canvas, 0, _canvas.Length); + _prevCanvas = null; + _frameIndex = 0; + } + + /// + /// Allocates a new WriteableBitmap matching this composer's canvas, suitable + /// as the reusable target for . + /// + public WriteableBitmap CreateMatchingBitmap() => + new WriteableBitmap( + new PixelSize(_apng.CanvasWidth, _apng.CanvasHeight), + new Vector(96, 96), + PixelFormat.Rgba8888, + AlphaFormat.Unpremul); + + private void AdvanceCanvasState(ApngRawFrame raw) + { + switch (raw.DisposeOp) + { + case DisposeOpBackground: + ClearRegion(_canvas, _apng.CanvasWidth, raw.X, raw.Y, raw.Width, raw.Height); + break; + case DisposeOpPrevious: + Buffer.BlockCopy(_prevCanvas!, 0, _canvas, 0, _canvas.Length); + _prevCanvas = null; + break; + // DisposeOpNone: leave canvas as-is + } + } + + public void Dispose() + { + _disposed = true; + _prevCanvas = null; + } + } /// - /// Reads a stream to verify if it is a valid APNG file - /// Checks for acTL chuck presence. - /// Doesn't close or disposes the stream. - /// Throws a exception if the stream doesn't contains a png file data + /// Returns true if is an APNG (has acTL chunk). + /// Does not close the stream. Throws if not a valid PNG. /// - /// - /// true if file is apng - /// public static bool IsApng(Stream pngStream) { if (pngStream == null || !pngStream.CanRead) - throw new ArgumentException("Stream inválido."); + throw new ArgumentException("Invalid stream."); - long originalPos = pngStream.Position; + long savedPos = pngStream.Position; + try + { + using var br = new BinaryReader(pngStream, Encoding.ASCII, leaveOpen: true); + if (!BytesEqual(br.ReadBytes(8), PngSignature)) + throw new Exception("Not a PNG file."); + + while (pngStream.Position < pngStream.Length) + { + int len = ReadBE32(br); + string type = ReadChunkType(br); + if (type == "acTL") return true; + pngStream.Seek(len + 4, SeekOrigin.Current); + if (type == "IEND") break; + } + return false; + } + finally { pngStream.Seek(savedPos, SeekOrigin.Begin); } + } + /// + /// Parses an APNG stream. Stores only the raw compressed frame bytes — + /// no pixels are decoded. Returns null on error. + /// Does not close the stream. + /// + public static ApngFile? ReadApng(Stream stream) + { try { - using (var br = new BinaryReader(pngStream, Encoding.ASCII, leaveOpen: true)) + stream.Seek(0, SeekOrigin.Begin); + using var br = new BinaryReader(stream, Encoding.ASCII, leaveOpen: true); + + if (!BytesEqual(br.ReadBytes(8), PngSignature)) + throw new Exception("Not a PNG file."); + + byte[]? ihdrData = null; + byte[]? trnsData = null; + int numPlays = 0; + var frames = new List(); + ApngRawFrame? cur = null; + var idatBufs = new List(); + + while (stream.Position < stream.Length) { - var sig = br.ReadBytes(8); - if (!BytesEqual(sig, PngSignature)) - throw new Exception("Incorrect PNG Signature"); + int len = ReadBE32(br); + string type = ReadChunkType(br); + byte[] data = br.ReadBytes(len); + br.ReadBytes(4); // CRC — skip - // Look for acTL o IEND - while (pngStream.Position < pngStream.Length) + switch (type) { - var lengthBytes = br.ReadBytes(4); - if (lengthBytes.Length < 4) break; - int length = ReadBigEndianInt(lengthBytes); + case "IHDR": ihdrData = data; break; + case "tRNS": trnsData = data; break; - var chunkType = Encoding.ASCII.GetString(br.ReadBytes(4)); + case "acTL": + numPlays = ReadBE32(data, 4); + break; - if (chunkType == "acTL") - { - return true; - } + case "fcTL": + if (cur != null && idatBufs.Count > 0) + { + cur.CompressedData = MergeByteArrays(idatBufs); + frames.Add(cur); + idatBufs = new List(); + } + cur = ParseFctl(data); + break; + + case "IDAT": + if (cur != null) idatBufs.Add(data); + break; - pngStream.Seek(length + 4, SeekOrigin.Current); + case "fdAT": + if (cur != null && data.Length > 4) + { + var payload = new byte[data.Length - 4]; + Buffer.BlockCopy(data, 4, payload, 0, payload.Length); + idatBufs.Add(payload); + } + break; - if (chunkType == "IEND") - break; + case "IEND": + if (cur != null && idatBufs.Count > 0) + { + cur.CompressedData = MergeByteArrays(idatBufs); + frames.Add(cur); + } + goto doneReading; } } - return false; + doneReading: + + if (ihdrData == null || frames.Count == 0) + throw new Exception("No frames found."); + + int canvasW = ReadBE32(ihdrData, 0); + int canvasH = ReadBE32(ihdrData, 4); + byte colorType = ihdrData[9]; + + (byte R, byte G, byte B)? transparentRgb = null; + if (trnsData != null && colorType == 2 && trnsData.Length >= 6) + transparentRgb = (trnsData[1], trnsData[3], trnsData[5]); + + return new ApngFile + { + CanvasWidth = canvasW, + CanvasHeight = canvasH, + NumPlays = numPlays, + ColorType = colorType, + TransparentRgb = transparentRgb, + Frames = frames + }; } - finally + catch (Exception ex) { - pngStream.Seek(originalPos, SeekOrigin.Begin); + Log.Add(Log.LogSeverity.Error, "APNGHelper.ReadApng", ex); + return null; } } + // Frame decode + + private static byte[] DecodeFrame( + ApngRawFrame raw, + byte colorType, + (byte R, byte G, byte B)? transparentRgb) + { + // Decompress zlib payload (skip 2-byte header: CMF + FLG) + byte[] pixels; + using (var ms = new MemoryStream(raw.CompressedData, 2, raw.CompressedData.Length - 2)) + using (var ds = new DeflateStream(ms, CompressionMode.Decompress)) + using (var buf = new MemoryStream()) + { + ds.CopyTo(buf); + pixels = buf.ToArray(); + } + + int w = raw.Width; + int h = raw.Height; + int bpp = colorType switch { 0 => 1, 2 => 3, 4 => 2, 6 => 4, _ => 3 }; - // Helpers + var rgba = new byte[w * h * 4]; + var prevRow = new byte[w * bpp]; + int stride = w * bpp + 1; // +1 for filter byte per row - private static bool BytesEqual(byte[] a, byte[] b) + for (int y = 0; y < h; y++) + { + byte filter = pixels[y * stride]; + var row = new byte[w * bpp]; + Buffer.BlockCopy(pixels, y * stride + 1, row, 0, row.Length); + ApplyPngFilter(filter, row, prevRow, bpp); + + for (int x = 0; x < w; x++) + { + int src = x * bpp; + int dst = (y * w + x) * 4; + byte r, g, b, a; + + switch (colorType) + { + case 2: // RGB + r = row[src]; g = row[src + 1]; b = row[src + 2]; + a = transparentRgb.HasValue + && r == transparentRgb.Value.R + && g == transparentRgb.Value.G + && b == transparentRgb.Value.B + ? (byte)0 : (byte)255; + break; + case 6: // RGBA + r = row[src]; g = row[src+1]; b = row[src+2]; a = row[src+3]; + break; + case 4: // Grayscale + alpha + r = g = b = row[src]; a = row[src + 1]; + break; + default: // Grayscale + r = g = b = row[src]; a = 255; + break; + } + + rgba[dst] = r; rgba[dst+1] = g; rgba[dst+2] = b; rgba[dst+3] = a; + } + + Buffer.BlockCopy(row, 0, prevRow, 0, row.Length); + } + + return rgba; + } + + private static void ApplyPngFilter(byte filter, byte[] row, byte[] prev, int bpp) { - if (a.Length != b.Length) return false; - for (int i = 0; i < a.Length; i++) - if (a[i] != b[i]) return false; - return true; + switch (filter) + { + case 1: // Sub + for (int i = bpp; i < row.Length; i++) + row[i] = (byte)(row[i] + row[i - bpp]); + break; + case 2: // Up + for (int i = 0; i < row.Length; i++) + row[i] = (byte)(row[i] + prev[i]); + break; + case 3: // Average + for (int i = 0; i < row.Length; i++) + { + byte left = i >= bpp ? row[i - bpp] : (byte)0; + row[i] = (byte)(row[i] + ((left + prev[i]) >> 1)); + } + break; + case 4: // Paeth + for (int i = 0; i < row.Length; i++) + { + byte left = i >= bpp ? row[i - bpp] : (byte)0; + byte upLeft = i >= bpp ? prev[i - bpp] : (byte)0; + row[i] = (byte)(row[i] + PaethPredictor(left, prev[i], upLeft)); + } + break; + // case 0 None: no-op + } + } + + private static byte PaethPredictor(byte a, byte b, byte c) + { + int p = a + b - c; + int pa = Math.Abs(p - a), pb = Math.Abs(p - b), pc = Math.Abs(p - c); + return pa <= pb && pa <= pc ? a : pb <= pc ? b : c; + } + + // Canvas ops + + private static void CompositeFrame( + byte[] canvas, int canvasW, byte[] frameRgba, ApngRawFrame raw) + { + int fw = raw.Width, fh = raw.Height, ox = raw.X, oy = raw.Y; + + for (int y = 0; y < fh; y++) + { + int rowSrc = y * fw * 4; + int rowDst = (oy + y) * canvasW * 4 + ox * 4; + + if (raw.BlendOp == BlendOpSource) + { + // Fast path: direct replace, one copy per row + Buffer.BlockCopy(frameRgba, rowSrc, canvas, rowDst, fw * 4); + } + else + { + // APNG_BLEND_OP_OVER: alpha composite per pixel + for (int x = 0; x < fw; x++) + { + int fi = rowSrc + x * 4; + int ci = rowDst + x * 4; + byte sA = frameRgba[fi + 3]; + + if (sA == 0) continue; // fully transparent: skip + byte sR = frameRgba[fi], sG = frameRgba[fi+1], sB = frameRgba[fi+2]; + + if (sA == 255) // fully opaque: fast replace + { + canvas[ci] = sR; canvas[ci+1] = sG; + canvas[ci+2] = sB; canvas[ci+3] = 255; + } + else // partial: blend + { + float sa = sA / 255f; + float da = canvas[ci+3] / 255f; + float oa = sa + da * (1f - sa); + if (oa > 0f) + { + float dsa = da * (1f - sa); + float inv = 1f / oa; + canvas[ci] = (byte)((sR * sa + canvas[ci] * dsa) * inv); + canvas[ci+1] = (byte)((sG * sa + canvas[ci+1] * dsa) * inv); + canvas[ci+2] = (byte)((sB * sa + canvas[ci+2] * dsa) * inv); + canvas[ci+3] = (byte)(oa * 255f); + } + } + } + } + } + } + + private static void ClearRegion(byte[] canvas, int canvasW, int x, int y, int w, int h) + { + for (int row = 0; row < h; row++) + Array.Clear(canvas, ((y + row) * canvasW + x) * 4, w * 4); + } + + private static WriteableBitmap CanvasToWriteableBitmap(byte[] canvas, int w, int h) + { + var bmp = new WriteableBitmap( + new PixelSize(w, h), + new Vector(96, 96), + PixelFormat.Rgba8888, + AlphaFormat.Unpremul); + using var fb = bmp.Lock(); + Marshal.Copy(canvas, 0, fb.Address, canvas.Length); + return bmp; + } + + // Chunk helpers + + private static ApngRawFrame ParseFctl(byte[] d) => new ApngRawFrame + { + // layout: seq(4) w(4) h(4) x(4) y(4) delayNum(2) delayDen(2) disposeOp(1) blendOp(1) + Width = ReadBE32(d, 4), + Height = ReadBE32(d, 8), + X = ReadBE32(d, 12), + Y = ReadBE32(d, 16), + DelayNum = ReadBE16(d, 20), + DelayDen = ReadBE16(d, 22), + DisposeOp = d[24], + BlendOp = d[25] + }; + + private static byte[] MergeByteArrays(List blocks) + { + int total = 0; + foreach (var b in blocks) total += b.Length; + var merged = new byte[total]; + int offset = 0; + foreach (var b in blocks) + { + Buffer.BlockCopy(b, 0, merged, offset, b.Length); + offset += b.Length; + } + return merged; + } + + private static string ReadChunkType(BinaryReader br) => + Encoding.ASCII.GetString(br.ReadBytes(4)); + + private static int ReadBE32(BinaryReader br) + { + var b = br.ReadBytes(4); + if (BitConverter.IsLittleEndian) Array.Reverse(b); + return BitConverter.ToInt32(b, 0); + } + + private static int ReadBE32(byte[] b, int offset = 0) + { + byte[] tmp = new byte[4]; + Buffer.BlockCopy(b, offset, tmp, 0, 4); + if (BitConverter.IsLittleEndian) Array.Reverse(tmp); + return BitConverter.ToInt32(tmp, 0); + } + + private static int ReadBE16(byte[] b, int offset = 0) + { + byte[] tmp = new byte[2]; + Buffer.BlockCopy(b, offset, tmp, 0, 2); + if (BitConverter.IsLittleEndian) Array.Reverse(tmp); + return (int)BitConverter.ToUInt16(tmp, 0); } - private static int ReadBigEndianInt(byte[] bytes) + private static bool BytesEqual(byte[] a, byte[] b) { - if (BitConverter.IsLittleEndian) Array.Reverse(bytes); - return BitConverter.ToInt32(bytes, 0); + if (a.Length != b.Length) return false; + for (int i = 0; i < a.Length; i++) if (a[i] != b[i]) return false; + return true; } } } diff --git a/Knossos.NET/Controls/AnimatedImage.cs b/Knossos.NET/Controls/AnimatedImage.cs new file mode 100644 index 00000000..f592f516 --- /dev/null +++ b/Knossos.NET/Controls/AnimatedImage.cs @@ -0,0 +1,398 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; +using Knossos.NET.Classes; +using System; +using System.Collections.Generic; +using System.IO; +using SystemPath = System.IO.Path; + +namespace Knossos.NET.Controls +{ + /// + /// Image control that auto-detects what its been given and animates accordingly: + /// + /// - Path is an APNG (.png/.apng) -> APNG animation + /// - Path is any other supported image -> static image (jpg, bmp, webp…) + /// - Stream is set -> Loads any static image or animation from a stream, + /// the Extension property must also be set + /// - Frames is set -> manual slideshow, bitmap list (overrides Path and Stream) + /// - FrameDelays -> list of delay for each frame for the slideshow + /// + /// "Path" accepts either a regular file path or an "avares://" URI. + /// Animation is driven by a single DispatcherTimer — when Playing=false the timer + /// is stopped and no CPU is used until Playing flips back to true. + /// + /// For APNG/ANI a single WriteableBitmap is allocated once and re-written every frame. + /// + /// Usage in XAML: + /// xmlns:c="using:VP.NET.GUI.Controls" + /// + /// + /// + /// + /// + /// + public class AnimatedImage : Image + { + // Styled properties + + public static readonly StyledProperty PathProperty = + AvaloniaProperty.Register(nameof(Path)); + + public static readonly StyledProperty StreamProperty = + AvaloniaProperty.Register(nameof(Stream)); + + public static readonly StyledProperty ExtensionProperty = + AvaloniaProperty.Register(nameof(Extension)); + + public static readonly StyledProperty PlayingProperty = + AvaloniaProperty.Register(nameof(Playing), defaultValue: true); + + public static readonly StyledProperty?> FramesProperty = + AvaloniaProperty.Register?>(nameof(Frames)); + + public static readonly StyledProperty?> FrameDelaysProperty = + AvaloniaProperty.Register?>(nameof(FrameDelays)); + + public static readonly StyledProperty DefaultFrameDelayProperty = + AvaloniaProperty.Register(nameof(DefaultFrameDelay), defaultValue: 100); + + /// File path or avares:// URI of the image to display. + public string? Path + { + get => GetValue(PathProperty); + set => SetValue(PathProperty, value); + } + + /// Any stream, an extension must be provided in the Extension property. The stream will not be disposed. + public Stream? Stream + { + get => GetValue(StreamProperty); + set => SetValue(StreamProperty, value); + } + + /// File extension (only used with stream). + public string? Extension + { + get => GetValue(ExtensionProperty); + set => SetValue(ExtensionProperty, value); + } + + /// True to play the animation, false to pause it. Has no effect on static images. + public bool Playing + { + get => GetValue(PlayingProperty); + set => SetValue(PlayingProperty, value); + } + + /// Optional manual frame collection. Takes priority over Path or Stream when set. + public IList? Frames + { + get => GetValue(FramesProperty); + set => SetValue(FramesProperty, value); + } + + /// Optional per-frame delays in milliseconds for manual mode. If null or shorter than Frames, DefaultFrameDelay is used. + public IList? FrameDelays + { + get => GetValue(FrameDelaysProperty); + set => SetValue(FrameDelaysProperty, value); + } + + /// Fallback delay in milliseconds for manual mode when FrameDelays is null or short. + public int DefaultFrameDelay + { + get => GetValue(DefaultFrameDelayProperty); + set => SetValue(DefaultFrameDelayProperty, value); + } + + // ─────────── Internal state ─────────── + + private enum Mode { None, Static, Apng, Ani, Manual } + + private Mode _mode = Mode.None; + private DispatcherTimer? _timer; + + private APNGHelper.ApngFile? _apngFile; + private APNGHelper.ApngComposer? _apngComposer; + private int _manualIndex; + private int _lastDelayMs = 100; + private WriteableBitmap? _reusableBitmap; + + // Lifecycle + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == PathProperty) + { + ReloadFromPath(); + } + else if (change.Property == StreamProperty) + { + ReloadFromStream(); + } + else if (change.Property == FramesProperty) + { + ReloadManual(); + } + else if (change.Property == PlayingProperty) + { + if (Playing) StartTimer(); + else StopTimer(); + } + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + // If we already have content and we are meant to play, (re)start the timer. + if (Playing) StartTimer(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + // Stop the timer so we dont fire ticks while invisible. + // We deliberately keep the decoded state around so reattaching is cheap; + // set Path = null (or Frames = null) to release the bitmap buffers. + StopTimer(); + base.OnDetachedFromVisualTree(e); + } + + // Loading + + private void DisposeAnimatedState() + { + StopTimer(); + _apngComposer?.Dispose(); + _apngComposer = null; + _apngFile = null; + _reusableBitmap?.Dispose(); + _reusableBitmap = null; + _manualIndex = 0; + _lastDelayMs = 100; + _mode = Mode.None; + } + + private void ReloadManual() + { + var frames = Frames; + if (frames != null && frames.Count > 0) + { + DisposeAnimatedState(); + _mode = Mode.Manual; + _manualIndex = 0; + _lastDelayMs = GetManualDelay(0); + Source = frames[0]; + + if (frames.Count > 1 && Playing) StartTimer(); + return; + } + + // Frames became null/empty: fall back to whatever Path says (if anything). + DisposeAnimatedState(); + Source = null; + ReloadFromPath(); + } + + private void ReloadFromStream() + { + if (Frames != null && Frames.Count > 0) return; + + DisposeAnimatedState(); + Source = null; + + string? ext = Extension; + Stream? stream = Stream; + if (stream == null || string.IsNullOrWhiteSpace(ext)) return; + + try + { + switch (ext) + { + case "png": + case "apng": + if (TryIsApng(stream)) + LoadApng(stream); + else + LoadStatic(stream); + break; + + default: + LoadStatic(stream); + break; + } + } + catch (Exception ex) + { + Log.Add(Log.LogSeverity.Error, "AnimatedImage.ReloadFromStream", ex); + } + } + + private void ReloadFromPath() + { + if (Frames != null && Frames.Count > 0) return; + + DisposeAnimatedState(); + Source = null; + + string? path = Path; + if (string.IsNullOrWhiteSpace(path)) return; + + try + { + string ext = SystemPath.GetExtension(path).ToLowerInvariant(); + using Stream stream = OpenStream(path); + + switch (ext) + { + case ".png": + case ".apng": + if (TryIsApng(stream)) + LoadApng(stream); + else + LoadStatic(stream); + break; + + default: + LoadStatic(stream); + break; + } + } + catch (Exception ex) + { + Log.Add(Log.LogSeverity.Error, "AnimatedImage.ReloadFromPath", ex); + } + } + + private static Stream OpenStream(string path) + { + Stream raw; + if (path.StartsWith("avares://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("resm:", StringComparison.OrdinalIgnoreCase)) + { + raw = AssetLoader.Open(new Uri(path)); + } + else + { + raw = File.OpenRead(path); + } + + // The helpers all do stream.Seek(0, ...), so make sure we hand them + // a seekable stream regardless of where it came from. + if (raw.CanSeek) return raw; + + var ms = new MemoryStream(); + using (raw) raw.CopyTo(ms); + ms.Position = 0; + return ms; + } + + private static bool TryIsApng(Stream stream) + { + try { return APNGHelper.IsApng(stream); } + catch { return false; } + } + + private void LoadStatic(Stream stream) + { + stream.Seek(0, SeekOrigin.Begin); + Source = new Bitmap(stream); + _mode = Mode.Static; + } + + private void LoadApng(Stream stream) + { + stream.Seek(0, SeekOrigin.Begin); + var apng = APNGHelper.ReadApng(stream); + if (apng == null || apng.Frames.Count == 0) + { + Log.Add(Log.LogSeverity.Error, "AnimatedImage.LoadApng", "Failed to parse APNG."); + return; + } + + _apngFile = apng; + _apngComposer = apng.CreateComposer(); + _reusableBitmap = _apngComposer.CreateMatchingBitmap(); + Source = _reusableBitmap; + _mode = Mode.Apng; + + // Render the first frame immediately so we always show something, + // even when Playing=false from the start. + _apngComposer.RenderNextFrameTo(_reusableBitmap, out _lastDelayMs); + InvalidateVisual(); + + if (apng.Frames.Count > 1 && Playing) StartTimer(); + } + + // Animation loop + + private void StartTimer() + { + if (_mode != Mode.Apng && _mode != Mode.Ani && _mode != Mode.Manual) return; + + _timer ??= new DispatcherTimer(); + _timer.Tick -= OnTick; + _timer.Tick += OnTick; + _timer.Interval = TimeSpan.FromMilliseconds(Math.Max(1, _lastDelayMs)); + _timer.Start(); + } + + private void StopTimer() + { + if (_timer == null) return; + _timer.Stop(); + _timer.Tick -= OnTick; + } + + private void OnTick(object? sender, EventArgs e) + { + try + { + switch (_mode) + { + case Mode.Apng: + if (_apngComposer != null && _reusableBitmap != null) + { + _apngComposer.RenderNextFrameTo(_reusableBitmap, out _lastDelayMs); + InvalidateVisual(); + } + break; + + case Mode.Manual: + var frames = Frames; + if (frames != null && frames.Count > 0) + { + _manualIndex = (_manualIndex + 1) % frames.Count; + Source = frames[_manualIndex]; + _lastDelayMs = GetManualDelay(_manualIndex); + } + break; + } + } + catch (Exception ex) + { + Log.Add(Log.LogSeverity.Error, "AnimatedImage.OnTick", ex); + StopTimer(); + return; + } + + if (_timer != null) + _timer.Interval = TimeSpan.FromMilliseconds(Math.Max(1, _lastDelayMs)); + } + + private int GetManualDelay(int index) + { + var delays = FrameDelays; + if (delays != null && index >= 0 && index < delays.Count) + return Math.Max(1, delays[index]); + return Math.Max(1, DefaultFrameDelay); + } + } +} diff --git a/Knossos.NET/Converters/RemotePathAssetValueConverter.cs b/Knossos.NET/Converters/RemotePathAssetValueConverter.cs new file mode 100644 index 00000000..1c1447aa --- /dev/null +++ b/Knossos.NET/Converters/RemotePathAssetValueConverter.cs @@ -0,0 +1,69 @@ +using Avalonia.Data.Converters; +using System; +using System.Globalization; +using Avalonia.Platform; +using Avalonia.Media.Imaging; +using System.IO; +using System.Threading.Tasks; + +namespace Knossos.NET.Converters +{ + public class RemotePathAssetValueConverter : IValueConverter + { + public static RemotePathAssetValueConverter Instance { get; } = new(); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo? culture) + { + try + { + if (value == null) return null; + + if(!targetType.IsAssignableFrom(typeof(string))) + throw new NotSupportedException(); + + if (value is not string rawUri) + { + if(parameter is string) + { + rawUri = (string)parameter; + } + else + { + throw new NotSupportedException(); + } + } + + if (rawUri.StartsWith("avares://")) + { + return rawUri; + } + else if (rawUri.ToLower().StartsWith("http")) + { + var localPath = Task.Run(() => KnUtils.GetRemoteResource(rawUri)).Result; + if (localPath != null) + { + return localPath; + } + } + else if (File.Exists(Path.Combine(KnUtils.GetKnossosDataFolderPath(), rawUri))) + { + return Path.Combine(KnUtils.GetKnossosDataFolderPath(), rawUri); + } + else if (File.Exists(rawUri)) + { + return rawUri; + } + } + catch (Exception ex) + { + Log.Add(Log.LogSeverity.Error, "RemotePathAssetValueConverter.Convert()", ex); + } + return null; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/Knossos.NET/Knossos.NET.csproj b/Knossos.NET/Knossos.NET.csproj index 31e9d4ea..ed803e1c 100644 --- a/Knossos.NET/Knossos.NET.csproj +++ b/Knossos.NET/Knossos.NET.csproj @@ -58,7 +58,6 @@ - diff --git a/Knossos.NET/ViewModels/ModListViewModel.cs b/Knossos.NET/ViewModels/ModListViewModel.cs index 914f51eb..217c835a 100644 --- a/Knossos.NET/ViewModels/ModListViewModel.cs +++ b/Knossos.NET/ViewModels/ModListViewModel.cs @@ -293,7 +293,7 @@ public void AddMod(Mod modJson) internal void ChangeSort(object sort) { Sorting = true; - LoadingAnimation.Animate = 1; + LoadingAnimation.Animate = true; var newSort = ModSortType.unsorted; if (sort is ModSortType) { @@ -310,7 +310,7 @@ internal void ChangeSort(object sort) Mods.Sort(); //It will use ModCardViewModel.CompareTo() } SortString = "Sorted by " + newSort; - LoadingAnimation.Animate = 0; + LoadingAnimation.Animate = false; Sorting = false; } diff --git a/Knossos.NET/ViewModels/NebulaModListViewModel.cs b/Knossos.NET/ViewModels/NebulaModListViewModel.cs index 167bf07c..a1074ec2 100644 --- a/Knossos.NET/ViewModels/NebulaModListViewModel.cs +++ b/Knossos.NET/ViewModels/NebulaModListViewModel.cs @@ -30,11 +30,11 @@ internal bool isLoading{ ShowTiles = !isLoading; if (ShowTiles) { - LoadingAnimation.Animate = 0; + LoadingAnimation.Animate = false; } else { - LoadingAnimation.Animate = 1; + LoadingAnimation.Animate = true; } } } @@ -82,7 +82,7 @@ internal string Search public NebulaModListViewModel() { - LoadingAnimation.Animate = 1; + LoadingAnimation.Animate = true; CardsView = Mods.ToNotifyCollectionChangedSlim(SynchronizationContextCollectionEventDispatcher.Current); } @@ -211,7 +211,7 @@ public void OpenTab() Task.Run(() => { ShowTiles = false; - LoadingAnimation.Animate = 1; + LoadingAnimation.Animate = true; if (MainViewModel.Instance != null) { if (Search != MainViewModel.Instance.sharedSearch) @@ -231,7 +231,7 @@ public void OpenTab() { await card.LoadImage(); }); - LoadingAnimation.Animate = 0; + LoadingAnimation.Animate = false; ShowTiles = true; }); } diff --git a/Knossos.NET/ViewModels/Templates/LoadingIconViewModel.cs b/Knossos.NET/ViewModels/Templates/LoadingIconViewModel.cs index 9cba2170..2f432a0a 100644 --- a/Knossos.NET/ViewModels/Templates/LoadingIconViewModel.cs +++ b/Knossos.NET/ViewModels/Templates/LoadingIconViewModel.cs @@ -7,6 +7,7 @@ namespace Knossos.NET.ViewModels { public partial class LoadingIconViewModel : ViewModelBase { - public int Animate {get; set;} = 0; + [ObservableProperty] + public bool animate = false; } } diff --git a/Knossos.NET/ViewModels/Templates/TaskInfoButtonViewModel.cs b/Knossos.NET/ViewModels/Templates/TaskInfoButtonViewModel.cs index d348c784..a8c89000 100644 --- a/Knossos.NET/ViewModels/Templates/TaskInfoButtonViewModel.cs +++ b/Knossos.NET/ViewModels/Templates/TaskInfoButtonViewModel.cs @@ -1,7 +1,4 @@ using CommunityToolkit.Mvvm.ComponentModel; -using System; -using System.Threading; -using System.Threading.Tasks; namespace Knossos.NET.ViewModels { @@ -11,13 +8,9 @@ public partial class TaskInfoButtonViewModel : ViewModelBase [ObservableProperty] internal int taskNumber = 0; [ObservableProperty] - internal int animate = 0; - [ObservableProperty] internal string tooltip = ""; [ObservableProperty] - internal bool frame0 = true; - [ObservableProperty] - internal bool frame1 = false; + internal bool animate = true; public TaskInfoButtonViewModel() { @@ -46,17 +39,17 @@ private void Update(object? _, System.Timers.ElapsedEventArgs __) Tooltip = "Open Task List\n\n" + TaskViewModel.GetRunningTaskString(); if (!TaskViewModel.IsSafeState()) { - Animate = 1; + Animate = true; } else { - Animate = 0; + Animate = false; } } else { Tooltip = "Open Task List"; - Animate = 0; + Animate = false; } } } diff --git a/Knossos.NET/ViewModels/Windows/ModDetailsViewModel.cs b/Knossos.NET/ViewModels/Windows/ModDetailsViewModel.cs index d10e16de..27af0a09 100644 --- a/Knossos.NET/ViewModels/Windows/ModDetailsViewModel.cs +++ b/Knossos.NET/ViewModels/Windows/ModDetailsViewModel.cs @@ -86,8 +86,6 @@ public partial class ModDetailsViewModel : ViewModelBase [ObservableProperty] internal string? banner = null; [ObservableProperty] - internal string? apngBanner = null; - [ObservableProperty] internal bool forumAvailable = false; [ObservableProperty] internal bool isInstalled = false; @@ -286,26 +284,9 @@ private void LoadBanner(int selectedIndex) var bannerLocalPath = modVersions[selectedIndex].fullPath + Path.DirectorySeparatorChar + modVersions[selectedIndex].banner; if (System.IO.File.Exists(bannerLocalPath)) { - var isApng = false; - using (var stream = new FileStream(bannerLocalPath, FileMode.Open, FileAccess.Read)) - { - try - { - isApng = APNGHelper.IsApng(stream); - - } - catch { /* Not a valid png*/ } - } Dispatcher.UIThread.Invoke(() => { - if (isApng) - { - ApngBanner = bannerLocalPath; - } - else - { - Banner = bannerLocalPath; - } + Banner = bannerLocalPath; }); } else @@ -318,26 +299,9 @@ private void LoadBanner(int selectedIndex) var fs = await KnUtils.GetRemoteResource(url).ConfigureAwait(false); if (fs != null) { - var isApng = false; - using (var stream = new FileStream(fs, FileMode.Open, FileAccess.Read)) - { - try - { - isApng = APNGHelper.IsApng(stream); - - } - catch { /* Not a valid png*/ } - } Dispatcher.UIThread.Invoke(() => { - if (isApng) - { - ApngBanner = fs; - } - else - { - Banner = fs; - } + Banner = fs; }); } }).ConfigureAwait(false); diff --git a/Knossos.NET/Views/CustomHomeView.axaml b/Knossos.NET/Views/CustomHomeView.axaml index b98ef0e5..cff545e7 100644 --- a/Knossos.NET/Views/CustomHomeView.axaml +++ b/Knossos.NET/Views/CustomHomeView.axaml @@ -6,8 +6,8 @@ x:Class="Knossos.NET.Views.CustomHomeView" xmlns:v="using:Knossos.NET.Views" xmlns:vm="using:Knossos.NET.ViewModels" - xmlns:anim="https://github.com/whistyun/AnimatedImage.Avalonia" x:DataType="vm:CustomHomeViewModel" + xmlns:c="using:Knossos.NET.Controls" Name="CustomTCView" Background="{StaticResource BackgroundColorPrimary}" xmlns:HtmlRenderer="clr-namespace:TheArtOfDev.HtmlRenderer.Avalonia;assembly=Avalonia.HtmlRenderer" @@ -17,16 +17,14 @@ - + - + + diff --git a/Knossos.NET/Views/Templates/LoadingIconView.axaml b/Knossos.NET/Views/Templates/LoadingIconView.axaml index af5fb6f9..4933695d 100644 --- a/Knossos.NET/Views/Templates/LoadingIconView.axaml +++ b/Knossos.NET/Views/Templates/LoadingIconView.axaml @@ -6,11 +6,11 @@ xmlns:anim="https://github.com/whistyun/AnimatedImage.Avalonia" x:Class="Knossos.NET.Views.LoadingIconView" xmlns:v="using:Knossos.NET.Views" + xmlns:c="using:Knossos.NET.Controls" xmlns:vm="using:Knossos.NET.ViewModels" x:DataType="vm:LoadingIconViewModel"> - - + diff --git a/Knossos.NET/Views/Templates/TaskInfoButtonView.axaml b/Knossos.NET/Views/Templates/TaskInfoButtonView.axaml index 0ad27ccc..f1fe9b6c 100644 --- a/Knossos.NET/Views/Templates/TaskInfoButtonView.axaml +++ b/Knossos.NET/Views/Templates/TaskInfoButtonView.axaml @@ -3,16 +3,15 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="40" d:DesignHeight="40" - xmlns:anim="https://github.com/whistyun/AnimatedImage.Avalonia" x:Class="Knossos.NET.Views.TaskInfoButtonView" xmlns:v="using:Knossos.NET.Views" + xmlns:c="using:Knossos.NET.Controls" xmlns:vm="using:Knossos.NET.ViewModels" x:DataType="vm:TaskInfoButtonViewModel"> - - + diff --git a/Knossos.NET/Views/Windows/ModDetailsView.axaml b/Knossos.NET/Views/Windows/ModDetailsView.axaml index 9c86f9a9..f5e2453f 100644 --- a/Knossos.NET/Views/Windows/ModDetailsView.axaml +++ b/Knossos.NET/Views/Windows/ModDetailsView.axaml @@ -10,12 +10,12 @@ xmlns:HtmlRenderer="clr-namespace:TheArtOfDev.HtmlRenderer.Avalonia;assembly=Avalonia.HtmlRenderer" Icon="/Assets/knossos-icon.ico" WindowStartupLocation="CenterOwner" + xmlns:c="using:Knossos.NET.Controls" MinHeight="400" MinWidth="400" WindowWidth="1070" Title="{Binding Name}" CanResize="True" - xmlns:anim="https://github.com/whistyun/AnimatedImage.Avalonia" xmlns:cvt="clr-namespace:Knossos.NET.Converters;assembly=Knossos.NET"> @@ -23,13 +23,13 @@ - + - +