From 5bbfcac43d334b0b1bbe2ee9085fa111214689a4 Mon Sep 17 00:00:00 2001 From: Salvador Cipolla Date: Fri, 17 Jul 2026 21:39:45 -0300 Subject: [PATCH] Implement own animation player --- Directory.Packages.props | 1 - Knossos.NET/Assets/general/knossos-1.gif | Bin 9508 -> 0 bytes Knossos.NET/Assets/general/knossos-2.gif | Bin 28443 -> 0 bytes Knossos.NET/Assets/general/knossos.apng | Bin 0 -> 31564 bytes Knossos.NET/Classes/APNGHelper.cs | 605 ++++++++++++++++-- Knossos.NET/Controls/AnimatedImage.cs | 398 ++++++++++++ .../RemotePathAssetValueConverter.cs | 69 ++ Knossos.NET/Knossos.NET.csproj | 1 - Knossos.NET/ViewModels/ModListViewModel.cs | 4 +- .../ViewModels/NebulaModListViewModel.cs | 10 +- .../Templates/LoadingIconViewModel.cs | 3 +- .../Templates/TaskInfoButtonViewModel.cs | 15 +- .../ViewModels/Windows/ModDetailsViewModel.cs | 40 +- Knossos.NET/Views/CustomHomeView.axaml | 10 +- .../Views/Templates/LoadingIconView.axaml | 4 +- .../Views/Templates/TaskInfoButtonView.axaml | 5 +- .../Views/Windows/ModDetailsView.axaml | 8 +- 17 files changed, 1057 insertions(+), 116 deletions(-) delete mode 100644 Knossos.NET/Assets/general/knossos-1.gif delete mode 100644 Knossos.NET/Assets/general/knossos-2.gif create mode 100644 Knossos.NET/Assets/general/knossos.apng create mode 100644 Knossos.NET/Controls/AnimatedImage.cs create mode 100644 Knossos.NET/Converters/RemotePathAssetValueConverter.cs 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 99c878a11800c24b7acdec860b8dff4ff489b4e0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9508 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 DjOC6% diff --git a/Knossos.NET/Assets/general/knossos-2.gif b/Knossos.NET/Assets/general/knossos-2.gif deleted file mode 100644 index 3cbc62ec7094a437e0baa0904eefc5e68a805b2c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28443 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 rOW3)n1I22N6WQVQcfVud`ABTHdoPP diff --git a/Knossos.NET/Assets/general/knossos.apng b/Knossos.NET/Assets/general/knossos.apng new file mode 100644 index 0000000000000000000000000000000000000000..2685d016c1b1013c0d32b9a42504c8ca0030613d GIT binary patch literal 31564 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Ð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 literal 0 HcmV?d00001 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 @@ - + - +