Browse Source

[Avalonia] Animated Gifs and Webp #150

Ruben 1 year ago
parent
commit
bf5aff2749
36 changed files with 1555 additions and 83 deletions
  1. 74 0
      src/PicView.Avalonia/AnimatedImage/CustomVisualHandler.cs
  2. 9 0
      src/PicView.Avalonia/AnimatedImage/Decoding/BlockTypes.cs
  3. 7 0
      src/PicView.Avalonia/AnimatedImage/Decoding/ExtensionType.cs
  4. 9 0
      src/PicView.Avalonia/AnimatedImage/Decoding/FrameDisposal.cs
  5. 36 0
      src/PicView.Avalonia/AnimatedImage/Decoding/GifColor.cs
  6. 659 0
      src/PicView.Avalonia/AnimatedImage/Decoding/GifDecoder.cs
  7. 14 0
      src/PicView.Avalonia/AnimatedImage/Decoding/GifFrame.cs
  8. 14 0
      src/PicView.Avalonia/AnimatedImage/Decoding/GifHeader.cs
  9. 34 0
      src/PicView.Avalonia/AnimatedImage/Decoding/GifRect.cs
  10. 7 0
      src/PicView.Avalonia/AnimatedImage/Decoding/GifRepeatBehavior.cs
  11. 10 0
      src/PicView.Avalonia/AnimatedImage/Decoding/InvalidGifStreamException.cs
  12. 10 0
      src/PicView.Avalonia/AnimatedImage/Decoding/LzwDecompressionException.cs
  13. 78 0
      src/PicView.Avalonia/AnimatedImage/Extensions/StreamExtensions.cs
  14. 154 0
      src/PicView.Avalonia/AnimatedImage/GifInstance.cs
  15. 16 0
      src/PicView.Avalonia/AnimatedImage/IGifInstance.cs
  16. 10 0
      src/PicView.Avalonia/AnimatedImage/InvalidGifStreamException.cs
  17. 181 0
      src/PicView.Avalonia/AnimatedImage/WebpInstance.cs
  18. 2 1
      src/PicView.Avalonia/Clipboard/ClipboardHelper.cs
  19. 78 5
      src/PicView.Avalonia/CustomControls/PicBox.cs
  20. 2 1
      src/PicView.Avalonia/FileSystem/FilePickerHelper.cs
  21. 94 36
      src/PicView.Avalonia/ImageHandling/ImageHelper.cs
  22. 1 0
      src/PicView.Avalonia/Navigation/ErrorHandling.cs
  23. 1 1
      src/PicView.Avalonia/Navigation/ExifHandling.cs
  24. 8 3
      src/PicView.Avalonia/Navigation/ImageIterator.cs
  25. 2 1
      src/PicView.Avalonia/Navigation/ImageType.cs
  26. 4 0
      src/PicView.Avalonia/Navigation/QuickLoad.cs
  27. 1 3
      src/PicView.Avalonia/UI/SetTitleHelper.cs
  28. 1 0
      src/PicView.Avalonia/UI/StartUpHelper.cs
  29. 2 1
      src/PicView.Avalonia/UI/WindowHelper.cs
  30. 1 1
      src/PicView.Avalonia/Views/StartUpMenu.axaml
  31. 1 1
      src/PicView.Avalonia/Views/StartUpMenu.axaml.cs
  32. 32 0
      src/PicView.Core/ImageDecoding/ImageFunctionHelper.cs
  33. 1 1
      src/PicView.WPF/ChangeImage/QuickLoad.cs
  34. 1 1
      src/PicView.WPF/ChangeImage/UpdateImage.cs
  35. 0 26
      src/PicView.WPF/ImageHandling/ImageFunctions.cs
  36. 1 1
      src/PicView.sln

+ 74 - 0
src/PicView.Avalonia/AnimatedImage/CustomVisualHandler.cs

@@ -0,0 +1,74 @@
+using Avalonia;
+using Avalonia.Logging;
+using Avalonia.Media;
+using Avalonia.Rendering.Composition;
+
+namespace PicView.Avalonia.AnimatedImage;
+
+public class CustomVisualHandler : CompositionCustomVisualHandler
+{
+    private TimeSpan _animationElapsed;
+    private TimeSpan? _lastServerTime;
+    private IGifInstance? _currentInstance;
+    private bool _running;
+
+    public static readonly object StopMessage = new(),
+        StartMessage = new();
+
+    public override void OnMessage(object message)
+    {
+        if (message == StartMessage)
+        {
+            _running = true;
+            _lastServerTime = null;
+            RegisterForNextAnimationFrameUpdate();
+        }
+        else if (message == StopMessage)
+        {
+            _running = false;
+        }
+        else if (message is IGifInstance instance)
+        {
+            _currentInstance?.Dispose();
+            _currentInstance = instance;
+        }
+    }
+
+    public override void OnAnimationFrameUpdate()
+    {
+        if (!_running)
+            return;
+        Invalidate();
+        RegisterForNextAnimationFrameUpdate();
+    }
+
+    public override void OnRender(ImmediateDrawingContext drawingContext)
+    {
+        if (_running)
+        {
+            if (_lastServerTime.HasValue)
+                _animationElapsed += (CompositionNow - _lastServerTime.Value);
+            _lastServerTime = CompositionNow;
+        }
+
+        try
+        {
+            if (_currentInstance is null || _currentInstance.IsDisposed)
+                return;
+
+            var bitmap = _currentInstance.ProcessFrameTime(_animationElapsed);
+            if (bitmap is not null)
+            {
+                drawingContext.DrawBitmap(
+                    bitmap,
+                    new Rect(_currentInstance.GifPixelSize.ToSize(1)),
+                    GetRenderBounds()
+                );
+            }
+        }
+        catch (Exception e)
+        {
+            Logger.Sink?.Log(LogEventLevel.Error, "GifImage Renderer ", this, e.ToString());
+        }
+    }
+}

+ 9 - 0
src/PicView.Avalonia/AnimatedImage/Decoding/BlockTypes.cs

@@ -0,0 +1,9 @@
+namespace PicView.Avalonia.AnimatedImage.Decoding;
+internal enum BlockTypes
+{
+    Empty = 0,
+    Extension = 0x21,
+    ImageDescriptor = 0x2C,
+    Trailer = 0x3B,
+}
+

+ 7 - 0
src/PicView.Avalonia/AnimatedImage/Decoding/ExtensionType.cs

@@ -0,0 +1,7 @@
+namespace PicView.Avalonia.AnimatedImage.Decoding;
+internal enum ExtensionType
+{
+    GraphicsControl = 0xF9,
+    Application = 0xFF
+}
+

+ 9 - 0
src/PicView.Avalonia/AnimatedImage/Decoding/FrameDisposal.cs

@@ -0,0 +1,9 @@
+namespace PicView.Avalonia.AnimatedImage.Decoding;
+public enum FrameDisposal
+{
+    Unknown = 0,
+    Leave = 1,
+    Background = 2,
+    Restore = 3
+}
+

+ 36 - 0
src/PicView.Avalonia/AnimatedImage/Decoding/GifColor.cs

@@ -0,0 +1,36 @@
+using System.Runtime.InteropServices;
+
+namespace PicView.Avalonia.AnimatedImage.Decoding;
+    
+[StructLayout(LayoutKind.Explicit)]
+public readonly struct GifColor
+{
+    [FieldOffset(3)]
+    public readonly byte A;
+
+    [FieldOffset(2)]
+    public readonly byte R;
+
+    [FieldOffset(1)]
+    public readonly byte G;
+
+    [FieldOffset(0)]
+    public readonly byte B;
+
+    /// <summary>
+    /// A struct that represents a ARGB color and is aligned as
+    /// a BGRA bytefield in memory.
+    /// </summary>
+    /// <param name="r">Red</param>
+    /// <param name="g">Green</param>
+    /// <param name="b">Blue</param>
+    /// <param name="a">Alpha</param>
+    public GifColor(byte r, byte g, byte b, byte a = byte.MaxValue)
+    {
+        A = a;
+        R = r;
+        G = g;
+        B = b;
+    }
+}
+

+ 659 - 0
src/PicView.Avalonia/AnimatedImage/Decoding/GifDecoder.cs

@@ -0,0 +1,659 @@
+// This source file's Lempel-Ziv-Welch algorithm is derived from Chromium's Android GifPlayer
+// as seen here (https://github.com/chromium/chromium/blob/master/third_party/gif_player/src/jp/tomorrowkey/android/gifplayer)
+// Licensed under the Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
+// Copyright (C) 2015 The Gifplayer Authors. All Rights Reserved.
+
+// The rest of the source file is licensed under MIT License.
+// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved.
+
+using System.Buffers;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Text;
+using Avalonia;
+using Avalonia.Media.Imaging;
+using static PicView.Avalonia.AnimatedImage.Extensions.StreamExtensions;
+
+namespace PicView.Avalonia.AnimatedImage.Decoding;
+public sealed class GifDecoder : IDisposable
+{
+    private static readonly ReadOnlyMemory<byte> G87AMagic
+        = "GIF87a"u8.ToArray().AsMemory();
+
+    private static readonly ReadOnlyMemory<byte> G89AMagic
+        = "GIF89a"u8.ToArray().AsMemory();
+
+    private static readonly ReadOnlyMemory<byte> NetscapeMagic
+        = "NETSCAPE2.0"u8.ToArray().AsMemory();
+
+    private static readonly TimeSpan FrameDelayThreshold = TimeSpan.FromMilliseconds(10);
+    private static readonly TimeSpan FrameDelayDefault = TimeSpan.FromMilliseconds(100);
+    private static readonly GifColor TransparentColor = new(0, 0, 0, 0);
+    private static readonly int MaxTempBuf = 768;
+    private static readonly int MaxStackSize = 4096;
+    private static readonly int MaxBits = 4097;
+
+    private readonly Stream _fileStream;
+    private readonly CancellationToken _currentCtsToken;
+    private readonly bool _hasFrameBackups;
+
+    private int _gctSize, _prevFrame = -1, _backupFrame = -1;
+    private bool _gctUsed;
+
+    private GifRect _gifDimensions;
+
+    private readonly int _backBufferBytes;
+    private GifColor[]? _bitmapBackBuffer;
+
+    private short[]? _prefixBuf;
+    private byte[]? _suffixBuf;
+    private byte[]? _pixelStack;
+    private byte[]? _indexBuf;
+    private byte[]? _backupFrameIndexBuf;
+    private volatile bool _hasNewFrame;
+
+    public GifHeader? Header { get; private set; }
+
+    public readonly List<GifFrame> Frames = new();
+
+    public PixelSize Size => new(Header?.Dimensions.Width ?? 0, Header?.Dimensions.Height ?? 0);
+
+    public GifDecoder(Stream fileStream, CancellationToken currentCtsToken)
+    {
+            _fileStream = fileStream;
+            _currentCtsToken = currentCtsToken;
+
+            ProcessHeaderData();
+            ProcessFrameData();
+
+            if (Header != null)
+                Header.IterationCount = Header.Iterations switch
+                {
+                    -1 => new GifRepeatBehavior { Count = 1 },
+                    0 => new GifRepeatBehavior { LoopForever = true },
+                    > 0 => new GifRepeatBehavior { Count = Header.Iterations },
+                    _ => Header.IterationCount
+                };
+
+            var pixelCount = _gifDimensions.TotalPixels;
+
+            _hasFrameBackups = Frames
+                .Any(f => f.FrameDisposalMethod == FrameDisposal.Restore);
+
+            _bitmapBackBuffer = new GifColor[pixelCount];
+            _indexBuf = new byte[pixelCount];
+
+            if (_hasFrameBackups)
+                _backupFrameIndexBuf = new byte[pixelCount];
+
+            _prefixBuf = new short[MaxStackSize];
+            _suffixBuf = new byte[MaxStackSize];
+            _pixelStack = new byte[MaxStackSize + 1];
+
+            _backBufferBytes = pixelCount * Marshal.SizeOf(typeof(GifColor));
+        }
+
+    public void Dispose()
+    {
+            Frames.Clear();
+
+            _bitmapBackBuffer = null;
+            _prefixBuf = null;
+            _suffixBuf = null;
+            _pixelStack = null;
+            _indexBuf = null;
+            _backupFrameIndexBuf = null;
+        }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private int PixCoord(int x, int y) => x + y * _gifDimensions.Width;
+
+    static readonly (int Start, int Step)[] Pass =
+    {
+        (0, 8),
+        (4, 8),
+        (2, 4),
+        (1, 2)
+    };
+
+    private void ClearImage()
+    {
+        if (_bitmapBackBuffer != null)
+            Array.Fill(_bitmapBackBuffer, TransparentColor);
+
+        _prevFrame = -1;
+        _backupFrame = -1;
+    }
+
+    public void RenderFrame(int fIndex, WriteableBitmap writeableBitmap, bool forceClear = false)
+    {
+        if (_currentCtsToken.IsCancellationRequested)
+            return;
+
+        if (fIndex < 0 | fIndex >= Frames.Count)
+            return;
+
+        if (_prevFrame == fIndex)
+            return;
+
+        if (fIndex == 0 || forceClear || fIndex < _prevFrame)
+            ClearImage();
+
+        DisposePreviousFrame();
+
+        _prevFrame++;
+
+        // render intermediate frame
+        for (int idx = _prevFrame; idx < fIndex; ++idx)
+        {
+            var prevFrame = Frames[idx];
+
+            if (prevFrame.FrameDisposalMethod == FrameDisposal.Restore)
+                continue;
+
+            if (prevFrame.FrameDisposalMethod == FrameDisposal.Background)
+            {
+                ClearArea(prevFrame.Dimensions);
+                continue;
+            }
+
+            RenderFrameAt(idx, writeableBitmap);
+        }
+
+        RenderFrameAt(fIndex, writeableBitmap);
+    }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private void RenderFrameAt(int idx, WriteableBitmap writeableBitmap)
+    {
+        var tmpB = ArrayPool<byte>.Shared.Rent(MaxTempBuf);
+
+        var curFrame = Frames[idx];
+        DecompressFrameToIndexBuffer(curFrame, _indexBuf, tmpB);
+
+        if (_hasFrameBackups & curFrame.ShouldBackup
+            && _indexBuf != null && _backupFrameIndexBuf != null)
+        {
+            Buffer.BlockCopy(_indexBuf, 0,
+                _backupFrameIndexBuf, 0,
+                curFrame.Dimensions.TotalPixels);
+            _backupFrame = idx;
+        }
+
+        DrawFrame(curFrame, _indexBuf);
+
+        _prevFrame = idx;
+        _hasNewFrame = true;
+
+        using var lockedBitmap = writeableBitmap.Lock();
+        WriteBackBufToFb(lockedBitmap.Address);
+
+        ArrayPool<byte>.Shared.Return(tmpB);
+    }
+
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private void DrawFrame(GifFrame curFrame, Memory<byte> frameIndexSpan)
+    {
+        var activeColorTable =
+            curFrame.IsLocalColorTableUsed ? curFrame.LocalColorTable : Header?.GlobalColorTable;
+
+        var cX = curFrame.Dimensions.X;
+        var cY = curFrame.Dimensions.Y;
+        var cH = curFrame.Dimensions.Height;
+        var cW = curFrame.Dimensions.Width;
+        var tC = curFrame.TransparentColorIndex;
+        var hT = curFrame.HasTransparency;
+
+        if (curFrame.IsInterlaced)
+        {
+            int curSrcRow = 0;
+            for (var i = 0; i < 4; i++)
+            {
+                var curPass = Pass[i];
+                var y = curPass.Start;
+                while (y < cH)
+                {
+                    DrawRow(curSrcRow++, y);
+                    y += curPass.Step;
+                }
+            }
+        }
+        else
+        {
+            for (var i = 0; i < cH; i++)
+                DrawRow(i, i);
+        }
+
+        return;
+
+        void DrawRow(int srcRow, int destRow)
+        {
+            // Get the starting point of the current row on frame's index stream.
+            var indexOffset = srcRow * cW;
+
+            // Get the target backbuffer offset from the frames coords.
+            var targetOffset = PixCoord(cX, destRow + cY);
+            if (_bitmapBackBuffer == null) return;
+            var len = _bitmapBackBuffer.Length;
+
+            for (var i = 0; i < cW; i++)
+            {
+                var indexColor = frameIndexSpan.Span[indexOffset + i];
+
+                if (activeColorTable == null || targetOffset >= len ||
+                    indexColor > activeColorTable.Length) return;
+
+                if (!(hT & indexColor == tC))
+                    _bitmapBackBuffer[targetOffset] = activeColorTable[indexColor];
+
+                targetOffset++;
+            }
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private void DisposePreviousFrame()
+    {
+        if (_prevFrame == -1)
+            return;
+
+        var prevFrame = Frames[_prevFrame];
+
+        switch (prevFrame.FrameDisposalMethod)
+        {
+            case FrameDisposal.Background:
+                ClearArea(prevFrame.Dimensions);
+                break;
+            case FrameDisposal.Restore:
+                if (_hasFrameBackups && _backupFrame != -1)
+                    DrawFrame(Frames[_backupFrame], _backupFrameIndexBuf);
+                else
+                    ClearArea(prevFrame.Dimensions);
+                break;
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private void ClearArea(GifRect area)
+    {
+        if(_bitmapBackBuffer is null) return;
+        
+        for (var y = 0; y < area.Height; y++)
+        {
+            var targetOffset = PixCoord(area.X, y + area.Y);
+            for (var x = 0; x < area.Width; x++)
+                _bitmapBackBuffer[targetOffset + x] = TransparentColor;
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private void DecompressFrameToIndexBuffer(GifFrame curFrame, Span<byte> indexSpan, byte[] tempBuf)
+    {
+        if (_prefixBuf is null || _suffixBuf is null || _pixelStack is null) return;
+
+        _fileStream.Position = curFrame.LzwStreamPosition;
+        var totalPixels = curFrame.Dimensions.TotalPixels;
+
+        // Initialize GIF data stream decoder.
+        var dataSize = curFrame.LzwMinCodeSize;
+        var clear = 1 << dataSize;
+        var endOfInformation = clear + 1;
+        var available = clear + 2;
+        var oldCode = -1;
+        var codeSize = dataSize + 1;
+        var codeMask = (1 << codeSize) - 1;
+
+        for (var code = 0; code < clear; code++)
+        {
+            _prefixBuf[code] = 0;
+            _suffixBuf[code] = (byte)code;
+        }
+
+        // Decode GIF pixel stream.
+        int bits, first, top, pixelIndex;
+        var datum = bits = first = top = pixelIndex = 0;
+
+        while (pixelIndex < totalPixels)
+        {
+            var blockSize = _fileStream.ReadBlock(tempBuf);
+
+            if (blockSize == 0)
+                break;
+
+            var blockPos = 0;
+
+            while (blockPos < blockSize)
+            {
+                datum += tempBuf[blockPos] << bits;
+                blockPos++;
+
+                bits += 8;
+
+                while (bits >= codeSize)
+                {
+                    // Get the next code.
+                    var code = datum & codeMask;
+                    datum >>= codeSize;
+                    bits -= codeSize;
+
+                    // Interpret the code
+                    if (code == clear)
+                    {
+                        // Reset decoder.
+                        codeSize = dataSize + 1;
+                        codeMask = (1 << codeSize) - 1;
+                        available = clear + 2;
+                        oldCode = -1;
+                        continue;
+                    }
+
+                    // Check for explicit end-of-stream
+                    if (code == endOfInformation)
+                        return;
+
+                    if (oldCode == -1)
+                    {
+                        indexSpan[pixelIndex++] = _suffixBuf[code];
+                        oldCode = code;
+                        first = code;
+                        continue;
+                    }
+
+                    var inCode = code;
+                    if (code >= available)
+                    {
+                        _pixelStack[top++] = (byte)first;
+                        code = oldCode;
+
+                        if (top == MaxBits)
+                            ThrowLswException();
+                    }
+
+                    while (code >= clear)
+                    {
+                        if (code >= MaxBits || code == _prefixBuf[code])
+                            ThrowLswException();
+
+                        _pixelStack[top++] = _suffixBuf[code];
+                        code = _prefixBuf[code];
+
+                        if (top == MaxBits)
+                            ThrowLswException();
+                    }
+
+                    first = _suffixBuf[code];
+                    _pixelStack[top++] = (byte)first;
+
+                    // Add new code to the dictionary
+                    if (available < MaxStackSize)
+                    {
+                        _prefixBuf[available] = (short)oldCode;
+                        _suffixBuf[available] = (byte)first;
+                        available++;
+
+                        if ((available & codeMask) == 0 && available < MaxStackSize)
+                        {
+                            codeSize++;
+                            codeMask += available;
+                        }
+                    }
+
+                    oldCode = inCode;
+
+                    // Drain the pixel stack.
+                    do
+                    {
+                        indexSpan[pixelIndex++] = _pixelStack[--top];
+                    } while (top > 0);
+                }
+            }
+        }
+
+        while (pixelIndex < totalPixels)
+            indexSpan[pixelIndex++] = 0; // clear missing pixels
+    }
+
+    private static void ThrowLswException() => throw new LzwDecompressionException();
+
+    /// <summary>
+    /// Directly copies the <see cref="GifColor"/> struct array to a bitmap IntPtr.
+    /// </summary>
+    private void WriteBackBufToFb(IntPtr targetPointer)
+    {
+        if (_currentCtsToken.IsCancellationRequested)
+            return;
+
+        if (!(_hasNewFrame && _bitmapBackBuffer != null)) return;
+
+        unsafe
+        {
+            fixed (void* src = &_bitmapBackBuffer[0])
+                Buffer.MemoryCopy(src, targetPointer.ToPointer(), (uint)_backBufferBytes,
+                    (uint)_backBufferBytes);
+            _hasNewFrame = false;
+        }
+    }
+
+    /// <summary>
+    /// Processes GIF Header.
+    /// </summary>
+    private void ProcessHeaderData()
+    {
+        var str = _fileStream;
+        var tmpB = ArrayPool<byte>.Shared.Rent(MaxTempBuf);
+        var tempBuf = tmpB.AsSpan();
+
+        var _ = str.Read(tmpB, 0, 6);
+
+        if (!tempBuf[..3].SequenceEqual(G87AMagic[..3].Span))
+            throw new InvalidGifStreamException("Not a GIF stream.");
+
+        if (!(tempBuf[..6].SequenceEqual(G87AMagic.Span) |
+              tempBuf[..6].SequenceEqual(G89AMagic.Span)))
+            throw new InvalidGifStreamException("Unsupported GIF Version: " +
+                                                Encoding.ASCII.GetString(tempBuf[..6].ToArray()));
+
+        ProcessScreenDescriptor(tmpB);
+
+        Header = new GifHeader
+        {
+            Dimensions = _gifDimensions,
+            GlobalColorTable =
+                _gctUsed ? ProcessColorTable(ref str, tmpB, _gctSize) : Array.Empty<GifColor>(),
+            HeaderSize = _fileStream.Position
+        };
+
+        ArrayPool<byte>.Shared.Return(tmpB);
+    }
+
+    /// <summary>
+    /// Parses colors from file stream to target color table.
+    /// </summary> 
+    private static GifColor[] ProcessColorTable(ref Stream stream, byte[] rawBufSpan, int nColors)
+    {
+        var nBytes = 3 * nColors;
+        var target = new GifColor[nColors];
+
+        var n = stream.Read(rawBufSpan, 0, nBytes);
+
+        if (n < nBytes)
+            throw new InvalidOperationException("Wrong color table bytes.");
+
+        int i = 0, j = 0;
+
+        while (i < nColors)
+        {
+            var r = rawBufSpan[j++];
+            var g = rawBufSpan[j++];
+            var b = rawBufSpan[j++];
+            target[i++] = new GifColor(r, g, b);
+        }
+
+        return target;
+    }
+
+    /// <summary>
+    /// Parses screen and other GIF descriptors. 
+    /// </summary>
+    private void ProcessScreenDescriptor(byte[] tempBuf)
+    {
+        var width = _fileStream.ReadUShortS(tempBuf);
+        var height = _fileStream.ReadUShortS(tempBuf);
+
+        var packed = _fileStream.ReadByteS(tempBuf);
+
+        _gctUsed = (packed & 0x80) != 0;
+        _gctSize = 2 << (packed & 7);
+        _ = _fileStream.ReadByteS(tempBuf);
+
+        _gifDimensions = new GifRect(0, 0, width, height);
+        _fileStream.Skip(1);
+    }
+
+    /// <summary>
+    /// Parses all frame data.
+    /// </summary>
+    private void ProcessFrameData()
+    {
+        _fileStream.Position = Header?.HeaderSize ?? -1;
+
+        var tempBuf = ArrayPool<byte>.Shared.Rent(MaxTempBuf);
+
+        var terminate = false;
+        var curFrame = 0;
+
+        Frames.Add(new GifFrame());
+
+        do
+        {
+            var blockType = (BlockTypes)_fileStream.ReadByteS(tempBuf);
+
+            switch (blockType)
+            {
+                case BlockTypes.Empty:
+                    break;
+
+                case BlockTypes.Extension:
+                    ProcessExtensions(ref curFrame, tempBuf);
+                    break;
+
+                case BlockTypes.ImageDescriptor:
+                    ProcessImageDescriptor(ref curFrame, tempBuf);
+                    _fileStream.SkipBlocks(tempBuf);
+                    break;
+
+                case BlockTypes.Trailer:
+                    Frames.RemoveAt(Frames.Count - 1);
+                    terminate = true;
+                    break;
+
+                default:
+                    _fileStream.SkipBlocks(tempBuf);
+                    break;
+            }
+
+            // Break the loop when the stream is not valid anymore.
+            if (_fileStream.Position >= _fileStream.Length & terminate == false)
+                throw new InvalidProgramException("Reach the end of the filestream without trailer block.");
+        } while (!terminate);
+
+        ArrayPool<byte>.Shared.Return(tempBuf);
+    }
+
+    /// <summary>
+    /// Parses GIF Image Descriptor Block.
+    /// </summary>
+    private void ProcessImageDescriptor(ref int curFrame, byte[] tempBuf)
+    {
+        var str = _fileStream;
+        var currentFrame = Frames[curFrame];
+
+        // Parse frame dimensions.
+        var frameX = str.ReadUShortS(tempBuf);
+        var frameY = str.ReadUShortS(tempBuf);
+        var frameW = str.ReadUShortS(tempBuf);
+        var frameH = str.ReadUShortS(tempBuf);
+
+        frameW = (ushort)Math.Min(frameW, _gifDimensions.Width - frameX);
+        frameH = (ushort)Math.Min(frameH, _gifDimensions.Height - frameY);
+
+        currentFrame.Dimensions = new GifRect(frameX, frameY, frameW, frameH);
+
+        // Unpack interlace and lct info.
+        var packed = str.ReadByteS(tempBuf);
+        currentFrame.IsInterlaced = (packed & 0x40) != 0;
+        currentFrame.IsLocalColorTableUsed = (packed & 0x80) != 0;
+        currentFrame.LocalColorTableSize = (int)Math.Pow(2, (packed & 0x07) + 1);
+
+        if (currentFrame.IsLocalColorTableUsed)
+            currentFrame.LocalColorTable =
+                ProcessColorTable(ref str, tempBuf, currentFrame.LocalColorTableSize);
+
+        currentFrame.LzwMinCodeSize = str.ReadByteS(tempBuf);
+        currentFrame.LzwStreamPosition = str.Position;
+
+        curFrame += 1;
+        Frames.Add(new GifFrame());
+    }
+
+    /// <summary>
+    /// Parses GIF Extension Blocks.
+    /// </summary>
+    private void ProcessExtensions(ref int curFrame, byte[] tempBuf)
+    {
+        var extType = (ExtensionType)_fileStream.ReadByteS(tempBuf);
+
+        switch (extType)
+        {
+            case ExtensionType.GraphicsControl:
+
+                _fileStream.ReadBlock(tempBuf);
+                var currentFrame = Frames[curFrame];
+                var packed = tempBuf[0];
+
+                currentFrame.FrameDisposalMethod = (FrameDisposal)((packed & 0x1c) >> 2);
+
+                if (currentFrame.FrameDisposalMethod != FrameDisposal.Restore
+                    && currentFrame.FrameDisposalMethod != FrameDisposal.Background)
+                    currentFrame.ShouldBackup = true;
+
+                currentFrame.HasTransparency = (packed & 1) != 0;
+
+                currentFrame.FrameDelay =
+                    TimeSpan.FromMilliseconds(SpanToShort(tempBuf.AsSpan(1)) * 10);
+
+                if (currentFrame.FrameDelay <= FrameDelayThreshold)
+                    currentFrame.FrameDelay = FrameDelayDefault;
+
+                currentFrame.TransparentColorIndex = tempBuf[3];
+                break;
+
+            case ExtensionType.Application:
+                var blockLen = _fileStream.ReadBlock(tempBuf);
+                var _ = tempBuf.AsSpan(0, blockLen);
+                var blockHeader = tempBuf.AsSpan(0, NetscapeMagic.Length);
+
+                if (blockHeader.SequenceEqual(NetscapeMagic.Span))
+                {
+                    var count = 1;
+
+                    while (count > 0)
+                        count = _fileStream.ReadBlock(tempBuf);
+
+                    var iterationCount = SpanToShort(tempBuf.AsSpan(1));
+
+                    if (Header != null) 
+                        Header.Iterations = iterationCount;
+                }
+                else
+                    _fileStream.SkipBlocks(tempBuf);
+
+                break;
+
+            default:
+                _fileStream.SkipBlocks(tempBuf);
+                break;
+        }
+    }
+}

+ 14 - 0
src/PicView.Avalonia/AnimatedImage/Decoding/GifFrame.cs

@@ -0,0 +1,14 @@
+namespace PicView.Avalonia.AnimatedImage.Decoding;
+public class GifFrame
+{
+    public bool HasTransparency, IsInterlaced, IsLocalColorTableUsed;
+    public byte TransparentColorIndex;
+    public int LzwMinCodeSize, LocalColorTableSize;
+    public long LzwStreamPosition;
+    public TimeSpan FrameDelay;
+    public FrameDisposal FrameDisposalMethod;
+    public bool ShouldBackup;
+    public GifRect Dimensions;
+    public GifColor[]? LocalColorTable;
+}
+

+ 14 - 0
src/PicView.Avalonia/AnimatedImage/Decoding/GifHeader.cs

@@ -0,0 +1,14 @@
+// Licensed under the MIT License.
+// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved.
+
+namespace PicView.Avalonia.AnimatedImage.Decoding;
+
+public class GifHeader
+{
+    public long HeaderSize;
+    internal int Iterations = -1;
+    public GifRepeatBehavior? IterationCount;
+    public GifRect Dimensions;
+    public GifColor[]? GlobalColorTable;
+}
+

+ 34 - 0
src/PicView.Avalonia/AnimatedImage/Decoding/GifRect.cs

@@ -0,0 +1,34 @@
+namespace PicView.Avalonia.AnimatedImage.Decoding
+{
+    public readonly struct GifRect(int x, int y, int width, int height)
+    {
+        public int X { get; } = x;
+        public int Y { get; } = y;
+        public int Width { get; } = width;
+        public int Height { get; } = height;
+        public int TotalPixels { get; } = width * height;
+
+        public static bool operator ==(GifRect a, GifRect b)
+        {
+            return a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height;
+        }
+
+        public static bool operator !=(GifRect a, GifRect b)
+        {
+            return !(a == b);
+        }
+
+        public override bool Equals(object? obj)
+        {
+            if (obj == null || GetType() != obj.GetType())
+                return false;
+
+            return this == (GifRect)obj;
+        }
+
+        public override int GetHashCode()
+        {
+            return X.GetHashCode() ^ Y.GetHashCode() | Width.GetHashCode() ^ Height.GetHashCode();
+        }
+    }
+}

+ 7 - 0
src/PicView.Avalonia/AnimatedImage/Decoding/GifRepeatBehavior.cs

@@ -0,0 +1,7 @@
+namespace PicView.Avalonia.AnimatedImage.Decoding;
+public class GifRepeatBehavior
+{
+    public bool LoopForever { get; set; }
+    public int? Count { get; set; }
+}
+

+ 10 - 0
src/PicView.Avalonia/AnimatedImage/Decoding/InvalidGifStreamException.cs

@@ -0,0 +1,10 @@
+// Licensed under the MIT License.
+// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved.
+
+using System.Runtime.Serialization;
+
+namespace PicView.Avalonia.AnimatedImage.Decoding;
+
+[Serializable]
+public class InvalidGifStreamException(string message) : Exception(message);
+

+ 10 - 0
src/PicView.Avalonia/AnimatedImage/Decoding/LzwDecompressionException.cs

@@ -0,0 +1,10 @@
+// Licensed under the MIT License.
+// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved.
+
+using System.Runtime.Serialization;
+
+namespace PicView.Avalonia.AnimatedImage.Decoding;
+
+
+[Serializable]
+public class LzwDecompressionException : Exception;

+ 78 - 0
src/PicView.Avalonia/AnimatedImage/Extensions/StreamExtensions.cs

@@ -0,0 +1,78 @@
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+
+namespace PicView.Avalonia.AnimatedImage.Extensions;
+    
+[DebuggerStepThrough]
+internal static class StreamExtensions
+{
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public static ushort SpanToShort(Span<byte> b) => (ushort)(b[0] | (b[1] << 8));
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public static void Skip(this Stream stream, long count)
+    {
+        stream.Position += count;
+    }
+
+    /// <summary>
+    /// Read a Gif block from stream while advancing the position.
+    /// </summary>
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public static int ReadBlock(this Stream stream, byte[] tempBuf)
+    {
+        _ = stream.Read(tempBuf, 0, 1);
+
+        var blockLength = (int)tempBuf[0];
+
+        if (blockLength > 0)
+            _ = stream.Read(tempBuf, 0, blockLength);
+
+        // Guard against infinite loop.
+        if (stream.Position >= stream.Length)
+            throw new InvalidGifStreamException("Reach the end of the filestream without trailer block.");
+
+        return blockLength;
+    }
+
+    /// <summary>
+    /// Skips GIF blocks until it encounters an empty block.
+    /// </summary>
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public static void SkipBlocks(this Stream stream, byte[] tempBuf)
+    {
+        int blockLength;
+        do
+        {
+            _ = stream.Read(tempBuf, 0, 1);
+
+            blockLength = tempBuf[0];
+            stream.Position += blockLength;
+
+            // Guard against infinite loop.
+            if (stream.Position >= stream.Length)
+                throw new InvalidGifStreamException("Reach the end of the filestream without trailer block.");
+        } while (blockLength > 0);
+    }
+
+    /// <summary>
+    /// Read a <see cref="ushort"/> from stream by providing a temporary buffer.
+    /// </summary>
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public static ushort ReadUShortS(this Stream stream, byte[] tempBuf)
+    {
+        _ = stream.Read(tempBuf, 0, 2);
+        return SpanToShort(tempBuf);
+    }
+
+    /// <summary>
+    /// Read a <see cref="ushort"/> from stream by providing a temporary buffer.
+    /// </summary>
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public static byte ReadByteS(this Stream stream, byte[] tempBuf)
+    {
+        _ = stream.Read(tempBuf, 0, 1);
+        var finalVal = tempBuf[0];
+        return finalVal;
+    }
+}

+ 154 - 0
src/PicView.Avalonia/AnimatedImage/GifInstance.cs

@@ -0,0 +1,154 @@
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using PicView.Avalonia.AnimatedImage.Decoding;
+
+namespace PicView.Avalonia.AnimatedImage;
+
+public class GifInstance : IGifInstance
+{
+    public IterationCount IterationCount { get; set; }
+    public bool AutoStart { get; private set; } = true;
+    private readonly GifDecoder _gifDecoder;
+    private readonly WriteableBitmap _targetBitmap;
+    private TimeSpan _totalTime;
+    private readonly List<TimeSpan> _frameTimes;
+    private uint _iterationCount;
+    private int _currentFrameIndex;
+
+    public CancellationTokenSource CurrentCts { get; }
+
+    internal GifInstance(object newValue) : this(newValue switch
+    {
+        Stream s => s,
+        Uri u => GetStreamFromUri(u),
+        string str => GetStreamFromString(str),
+        _ => throw new InvalidDataException("Unsupported source object")
+    })
+    { }
+
+    public GifInstance(string uri) : this(GetStreamFromString(uri))
+    { }
+
+    public GifInstance(Uri uri) : this(GetStreamFromUri(uri))
+    { }
+
+    public GifInstance(Stream currentStream)
+    {
+        if (!currentStream.CanSeek)
+            throw new InvalidDataException("The provided stream is not seekable.");
+
+        if (!currentStream.CanRead)
+            throw new InvalidOperationException("Can't read the stream provided.");
+
+        currentStream.Seek(0, SeekOrigin.Begin);
+
+        CurrentCts = new CancellationTokenSource();
+
+        _gifDecoder = new GifDecoder(currentStream, CurrentCts.Token);
+        var pixSize = new PixelSize(_gifDecoder.Header.Dimensions.Width, _gifDecoder.Header.Dimensions.Height);
+
+        _targetBitmap = new WriteableBitmap(pixSize, new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Opaque);
+        GifPixelSize = pixSize;
+
+        _totalTime = TimeSpan.Zero;
+
+        _frameTimes = _gifDecoder.Frames.Select(frame =>
+        {
+            _totalTime = _totalTime.Add(frame.FrameDelay);
+            return _totalTime;
+        }).ToList();
+
+        _gifDecoder.RenderFrame(0, _targetBitmap);
+    }
+
+    private static Stream GetStreamFromString(string str)
+    {
+        if (!Uri.TryCreate(str, UriKind.RelativeOrAbsolute, out var res))
+        {
+            throw new InvalidCastException("The string provided can't be converted to URI.");
+        }
+
+        return GetStreamFromUri(res);
+    }
+
+    private static Stream GetStreamFromUri(Uri uri)
+    {
+        var uriString = uri.OriginalString.Trim();
+
+        if (!uriString.StartsWith("resm") && !uriString.StartsWith("avares"))
+            throw new InvalidDataException(
+                "The URI provided is not currently supported.");
+
+        var assetLocator = AssetLoader.Open(uri);
+
+        if (assetLocator is null)
+            throw new InvalidDataException(
+                "The resource URI was not found in the current assembly.");
+
+        return assetLocator;
+    }
+
+    public int GifFrameCount => _frameTimes.Count;
+
+    public PixelSize GifPixelSize { get; }
+    public bool IsDisposed { get; set; }
+
+    public void Dispose()
+    {
+        if (IsDisposed) return;
+            
+        GC.SuppressFinalize(this);
+
+        IsDisposed = true;
+        CurrentCts.Cancel();
+        _targetBitmap.Dispose();
+    }
+
+    [CanBeNull]
+    public WriteableBitmap? ProcessFrameTime(TimeSpan elapsed)
+    {
+        if (!IterationCount.IsInfinite && _iterationCount > IterationCount.Value)
+        {
+            return null;
+        }
+
+        if (CurrentCts.IsCancellationRequested)
+        {
+            return null;
+        }
+            
+        var totalTicks = _totalTime.Ticks;
+
+        if (totalTicks == 0)
+        {
+            return ProcessFrameIndex(0);
+        }
+
+        var elapsedTicks = elapsed.Ticks;
+        var timeModulus = TimeSpan.FromTicks(elapsedTicks % totalTicks);
+        var targetFrame = _frameTimes.FirstOrDefault(x => timeModulus < x);
+        var currentFrame = _frameTimes.IndexOf(targetFrame);
+        if (currentFrame == -1) currentFrame = 0;
+
+        if (_currentFrameIndex == currentFrame)
+            return _targetBitmap;
+
+        _iterationCount = (uint)(elapsedTicks / totalTicks);
+
+        return ProcessFrameIndex(currentFrame);
+    }
+
+    internal WriteableBitmap ProcessFrameIndex(int frameIndex)
+    {
+        _gifDecoder.RenderFrame(frameIndex, _targetBitmap);
+        _currentFrameIndex = frameIndex;
+
+        return _targetBitmap;
+    }
+}
+
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Delegate | AttributeTargets.Field, AllowMultiple = false, Inherited = true)]
+public sealed class CanBeNullAttribute : Attribute { }
+

+ 16 - 0
src/PicView.Avalonia/AnimatedImage/IGifInstance.cs

@@ -0,0 +1,16 @@
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Media.Imaging;
+
+namespace PicView.Avalonia.AnimatedImage;
+
+public interface IGifInstance : IDisposable
+{
+    IterationCount IterationCount { get; set; }
+    bool AutoStart { get; }
+    CancellationTokenSource CurrentCts { get; }
+    int GifFrameCount { get; }
+    PixelSize GifPixelSize { get; }
+    bool IsDisposed { get; }
+    WriteableBitmap? ProcessFrameTime(TimeSpan stopwatchElapsed);
+}

+ 10 - 0
src/PicView.Avalonia/AnimatedImage/InvalidGifStreamException.cs

@@ -0,0 +1,10 @@
+using System.Runtime.Serialization;
+
+namespace PicView.Avalonia.AnimatedImage;
+
+[Serializable]
+internal class InvalidGifStreamException(string message) : Exception
+{
+    public override string Message { get; } = message;
+}
+

+ 181 - 0
src/PicView.Avalonia/AnimatedImage/WebpInstance.cs

@@ -0,0 +1,181 @@
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using SkiaSharp;
+
+namespace PicView.Avalonia.AnimatedImage;
+
+public class WebpInstance : IGifInstance
+{
+    public IterationCount IterationCount { get; set; }
+    public bool AutoStart { get; private set; } = true;
+
+    private readonly WriteableBitmap? _targetBitmap;
+    private TimeSpan _totalTime;
+    private readonly List<TimeSpan> _frameTimes;
+    private uint _iterationCount;
+    private int _currentFrameIndex;
+
+    private SKCodec? _codec;
+
+    public CancellationTokenSource CurrentCts { get; }
+
+    internal WebpInstance(object newValue)
+        : this(
+            newValue switch
+            {
+                Stream s => s,
+                Uri u => GetStreamFromUri(u),
+                string str => GetStreamFromString(str),
+                _ => throw new InvalidDataException("Unsupported source object")
+            }
+        ) { }
+
+    public WebpInstance(string uri)
+        : this(GetStreamFromString(uri)) { }
+
+    public WebpInstance(Uri uri)
+        : this(GetStreamFromUri(uri)) { }
+
+    public WebpInstance(Stream currentStream)
+    {
+        if (!currentStream.CanSeek)
+            throw new InvalidDataException("The provided stream is not seekable.");
+
+        if (!currentStream.CanRead)
+            throw new InvalidOperationException("Can't read the stream provided.");
+
+        currentStream.Seek(0, SeekOrigin.Begin);
+
+        CurrentCts = new CancellationTokenSource();
+
+        var managedStream = new SKManagedStream(currentStream);
+        _codec = SKCodec.Create(managedStream);
+
+        var pixSize = new PixelSize(_codec.Info.Width, _codec.Info.Height);
+
+        _targetBitmap = new WriteableBitmap(pixSize, new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Opaque);
+        GifPixelSize = pixSize;
+
+        _totalTime = TimeSpan.Zero;
+
+        _frameTimes = _codec
+            .FrameInfo
+            .Select(frame =>
+            {
+                _totalTime = _totalTime.Add(TimeSpan.FromMilliseconds(frame.Duration));
+                return _totalTime;
+            })
+            .ToList();
+
+        RenderFrame(_codec, _targetBitmap, 0);
+    }
+
+    private static void RenderFrame(SKCodec codec, WriteableBitmap targetBitmap, int index)
+    {
+        codec.GetFrameInfo(index, out var frameInfo);
+
+        var info = new SKImageInfo(codec.Info.Width, codec.Info.Height);
+        var decodeInfo = info.WithAlphaType(frameInfo.AlphaType);
+
+        using var frameBuffer = targetBitmap.Lock();
+
+        var result = codec.GetPixels(decodeInfo, frameBuffer.Address, new SKCodecOptions(index));
+
+        if (result != SKCodecResult.Success)
+            throw new InvalidDataException($"Could not decode frame {index} of {codec.FrameCount}.");
+    }
+
+    private static void RenderFrame(SKCodec codec, WriteableBitmap targetBitmap, int index, int priorIndex)
+    {
+        codec.GetFrameInfo(index, out var frameInfo);
+
+        var info = new SKImageInfo(codec.Info.Width, codec.Info.Height);
+        var decodeInfo = info.WithAlphaType(frameInfo.AlphaType);
+
+        using var frameBuffer = targetBitmap.Lock();
+
+        var result = codec.GetPixels(decodeInfo, frameBuffer.Address, new SKCodecOptions(index, priorIndex));
+
+        if (result != SKCodecResult.Success)
+            throw new InvalidDataException($"Could not decode frame {index} of {codec.FrameCount}.");
+    }
+
+    private static Stream GetStreamFromString(string str)
+    {
+        if (!Uri.TryCreate(str, UriKind.RelativeOrAbsolute, out var res))
+        {
+            throw new InvalidCastException("The string provided can't be converted to URI.");
+        }
+
+        return GetStreamFromUri(res);
+    }
+
+    private static Stream GetStreamFromUri(Uri uri)
+    {
+        var uriString = uri.OriginalString.Trim();
+
+        if (!uriString.StartsWith("resm") && !uriString.StartsWith("avares"))
+        {
+            return new FileStream(uriString, FileMode.Open, FileAccess.Read);
+        }
+
+        return AssetLoader.Open(uri);
+    }
+
+    public int GifFrameCount => _frameTimes.Count;
+
+    public PixelSize GifPixelSize { get; }
+
+    public void Dispose()
+    {
+        IsDisposed = true;
+        CurrentCts.Cancel();
+        _targetBitmap?.Dispose();
+        _codec?.Dispose();
+    }
+
+    public bool IsDisposed { get; private set; }
+
+    public WriteableBitmap? ProcessFrameTime(TimeSpan stopwatchElapsed)
+    {
+        if (!IterationCount.IsInfinite && _iterationCount > IterationCount.Value)
+        {
+            return null;
+        }
+
+        if (CurrentCts.IsCancellationRequested || _targetBitmap is null)
+        {
+            return null;
+        }
+
+        var elapsedTicks = stopwatchElapsed.Ticks;
+        var timeModulus = TimeSpan.FromTicks(elapsedTicks % _totalTime.Ticks);
+        var targetFrame = _frameTimes.FirstOrDefault(x => timeModulus < x);
+        var currentFrame = _frameTimes.IndexOf(targetFrame);
+        if (currentFrame == -1)
+            currentFrame = 0;
+
+        if (_currentFrameIndex == currentFrame)
+            return _targetBitmap;
+
+        _iterationCount = (uint)(elapsedTicks / _totalTime.Ticks);
+
+        return ProcessFrameIndex(currentFrame);
+    }
+
+    internal WriteableBitmap ProcessFrameIndex(int frameIndex)
+    {
+        if (_codec is null)
+            throw new InvalidOperationException("The codec is null.");
+
+        if (_targetBitmap is null)
+            throw new InvalidOperationException("The target bitmap is null.");
+
+        RenderFrame(_codec, _targetBitmap, frameIndex, _currentFrameIndex);
+        _currentFrameIndex = frameIndex;
+
+        return _targetBitmap;
+    }
+}

+ 2 - 1
src/PicView.Avalonia/Clipboard/ClipboardHelper.cs

@@ -44,7 +44,8 @@ public static class ClipboardHelper
         {
             switch (vm.ImageType)
             {
-                case ImageType.AnimatedBitmap:
+                case ImageType.AnimatedGif:
+                case ImageType.AnimatedWebp:
                     throw new ArgumentOutOfRangeException();
                 case ImageType.Bitmap:
                     if (vm.ImageSource is not Bitmap bitmap)

+ 78 - 5
src/PicView.Avalonia/CustomControls/PicBox.cs

@@ -1,4 +1,6 @@
-using Avalonia;
+using System.Numerics;
+using Avalonia;
+using Avalonia.Animation;
 using Avalonia.Automation.Peers;
 using Avalonia.Controls;
 using Avalonia.Controls.Automation.Peers;
@@ -8,8 +10,11 @@ using Avalonia.Utilities;
 using PicView.Avalonia.Navigation;
 using ReactiveUI;
 using Avalonia.Media.Imaging;
+using Avalonia.Rendering.Composition;
 using Avalonia.Svg.Skia;
+using PicView.Avalonia.AnimatedImage;
 using PicView.Avalonia.UI;
+using Vector = Avalonia.Vector;
 
 
 namespace PicView.Avalonia.CustomControls;
@@ -38,6 +43,7 @@ public class PicBox : Control
 
     #region Properties
     
+    private CompositionCustomVisual? _customVisual;
     private readonly IDisposable? _imageTypeSubscription;
     
     /// <summary>
@@ -88,6 +94,13 @@ public class PicBox : Control
         set => SetValue(ImageTypeProperty, value);
     }
 
+    private FileStream? _stream;
+    private IGifInstance? _animInstance;
+    private readonly IterationCount _iterationCount = IterationCount.Infinite;
+    
+    public object? InitialAnimatedSource;
+    
+
     #endregion
 
     #region Rendering
@@ -104,6 +117,17 @@ public class PicBox : Control
         {
             case IImage source:
                 RenderBasedOnSettings(context, source);
+                if (ImageType is ImageType.AnimatedGif or ImageType.AnimatedWebp)
+                {
+                    var s = InitialAnimatedSource as string;
+                    if (!string.IsNullOrWhiteSpace(s))
+                    {
+                        context.Dispose(); // Fixes transparent images
+                        _stream = new FileStream(s, FileMode.Open, FileAccess.Read);
+                        UpdateAnimationInstance(_stream);
+                        AnimationUpdate();
+                    }
+                }
                 break;
             case string svg:
             {
@@ -279,6 +303,18 @@ public class PicBox : Control
     {
         return new ImageAutomationPeer(this);
     }
+    
+    
+    protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+    {
+        var compositor = ElementComposition.GetElementVisual(this)?.Compositor;
+        if (compositor == null || _customVisual?.Compositor == compositor)
+            return;
+        _customVisual = compositor.CreateCustomVisual(new CustomVisualHandler());
+        ElementComposition.SetElementChildVisual(this, _customVisual);
+        _customVisual.SendHandlerMessage(CustomVisualHandler.StartMessage);
+        base.OnAttachedToVisualTree(e);
+    }
 
     #endregion
     
@@ -295,13 +331,18 @@ public class PicBox : Control
                 }
                 var svgSource = SvgSource.Load(svg);
                 Source = new SvgImage { Source = svgSource };
+                _animInstance?.Dispose();
+                _stream?.Dispose();
                 break;
-            case ImageType.Bitmap:
+            case ImageType.AnimatedGif:
+            case ImageType.AnimatedWebp:
                 Source = Source as Bitmap;
+                _animInstance?.Dispose();
                 break;
-            case ImageType.AnimatedBitmap:
+            case ImageType.Bitmap:
                 Source = Source as Bitmap;
-                // TODO: Add animation
+                _animInstance?.Dispose();
+                _stream?.Dispose();
                 break;
             case ImageType.Invalid:
             default:
@@ -313,8 +354,40 @@ public class PicBox : Control
     #endregion
 
     #region Animation
+    
+
+    private void UpdateAnimationInstance(object source)
+    {
+        _animInstance?.Dispose();
+        if (ImageType == ImageType.AnimatedGif)
+        {
+            _animInstance = new GifInstance(source as FileStream);
+        }
+        else
+        {
+            _animInstance = new WebpInstance(source as FileStream);
+        }
+        _animInstance.IterationCount = _iterationCount;
+        _customVisual?.SendHandlerMessage(_animInstance);
+        AnimationUpdate();
+    }
+    
+    private void AnimationUpdate()
+    {
+        if (_customVisual is null || _animInstance is null)
+            return;
 
-    // TODO: Add Animation behavior
+        var sourceSize = Bounds.Size;
+        var viewPort = DetermineViewPort();
+
+        var scale = CalculateScaling(viewPort.Size, sourceSize);
+        var scaledSize = sourceSize * scale;
+        var destRect = viewPort.CenterRect(new Rect(scaledSize)).Intersect(viewPort);
+
+        _customVisual.Size = new Vector2((float)sourceSize.Width, (float)sourceSize.Height);
+
+        _customVisual.Offset = new Vector3((float)destRect.Position.X, (float)destRect.Position.Y, 0);
+    }
 
     #endregion
 }

+ 2 - 1
src/PicView.Avalonia/FileSystem/FilePickerHelper.cs

@@ -102,7 +102,8 @@ public static class FilePickerHelper
         {
             switch (vm.ImageType)
             {
-                case ImageType.AnimatedBitmap:
+                case ImageType.AnimatedGif:
+                case ImageType.AnimatedWebp:
                     throw new ArgumentOutOfRangeException();
                 case ImageType.Bitmap:
                     if (vm.ImageSource is not Bitmap bitmap)

+ 94 - 36
src/PicView.Avalonia/ImageHandling/ImageHelper.cs

@@ -10,6 +10,8 @@ namespace PicView.Avalonia.ImageHandling;
 
 public static class ImageHelper
 {
+    #region Image Handling
+    
     public static async Task<ImageModel?> GetImageModelAsync(FileInfo fileInfo, bool isThumb = false, int height = 0)
     {
         return await Task.Run(async () =>
@@ -20,9 +22,35 @@ public static class ImageHelper
             {
                 switch (fileInfo.Extension.ToLower())
                 {
+                    case ".webp":
+                        if (isThumb)
+                        {
+                            await AddThumbAsync(fileInfo, imageModel, height).ConfigureAwait(false);
+                        }
+                        else
+                        {
+                            await AddImageAsync(fileInfo, imageModel).ConfigureAwait(false);
+                            if (IsAnimated(fileInfo))
+                            {
+                                imageModel.ImageType = ImageType.AnimatedWebp;
+                            }
+                        }
+                        break;
                     case ".gif":
+                        if (isThumb)
+                        {
+                            await AddThumbAsync(fileInfo, imageModel, height).ConfigureAwait(false);
+                        }
+                        else
+                        {
+                            await AddImageAsync(fileInfo, imageModel).ConfigureAwait(false);
+                            if (IsAnimated(fileInfo))
+                            {
+                                imageModel.ImageType = ImageType.AnimatedGif;
+                            }
+                        }
+                        break;
                     case ".png":
-                    case ".webp":
                     case ".jpg":
                     case ".jpeg":
                     case ".jpe":
@@ -71,6 +99,8 @@ public static class ImageHelper
             return imageModel;
         });
     }
+    
+
 
     private static async Task AddImageAsync(FileInfo fileInfo, ImageModel imageModel)
     {
@@ -85,32 +115,6 @@ public static class ImageHelper
         Add(fs, imageModel);
     }
 
-    private static void AddSvgImage(FileInfo fileInfo, ImageModel imageModel)
-    {
-        var svg = new MagickImage();
-        svg.Ping(fileInfo.FullName);
-        imageModel.PixelWidth = svg.Width;
-        imageModel.PixelHeight = svg.Height;
-        imageModel.ImageType = ImageType.Svg;
-        imageModel.Image = fileInfo.FullName;
-    }
-
-    private static async Task AddBase64ImageAsync(FileInfo fileInfo, ImageModel imageModel, bool isThumb, int height)
-    {
-        using var magickImage = await ImageDecoder.Base64ToMagickImage(fileInfo.FullName).ConfigureAwait(false);
-        using var b64Stream = new MemoryStream();
-        if (isThumb)
-        {
-            magickImage.Thumbnail(0, height);
-        }
-        else
-        {
-            await magickImage.WriteAsync(b64Stream);
-            b64Stream.Position = 0;
-        }
-        Add(b64Stream, imageModel);
-    }
-
     private static async Task AddDefaultImageAsync(FileInfo fileInfo, ImageModel imageModel, bool isThumb, int height)
     {
         if (isThumb)
@@ -140,15 +144,12 @@ public static class ImageHelper
         }
     }
 
-    private static async Task AddThumbAsync(FileInfo fileInfo, ImageModel imageModel, int height)
-    {
-        var thumb = await GetThumbAsync(fileInfo.FullName, height, fileInfo).ConfigureAwait(false);
-        imageModel.Image = thumb;
-        imageModel.PixelWidth = thumb?.PixelSize.Width ?? 0;
-        imageModel.PixelHeight = thumb?.PixelSize.Height ?? 0;
-        imageModel.ImageType = ImageType.Bitmap;
-    }
 
+    
+    #endregion
+    
+    #region Bitmap
+    
     private static void Add(Stream stream, ImageModel imageModel)
     {
         var bitmap = new Bitmap(stream);
@@ -157,6 +158,44 @@ public static class ImageHelper
         imageModel.PixelHeight = bitmap?.PixelSize.Height ?? 0;
         imageModel.ImageType = ImageType.Bitmap;
     }
+    
+    #endregion
+    
+    #region SVG
+    
+    private static void AddSvgImage(FileInfo fileInfo, ImageModel imageModel)
+    {
+        var svg = new MagickImage();
+        svg.Ping(fileInfo.FullName);
+        imageModel.PixelWidth = svg.Width;
+        imageModel.PixelHeight = svg.Height;
+        imageModel.ImageType = ImageType.Svg;
+        imageModel.Image = fileInfo.FullName;
+    }
+    
+    #endregion
+
+    #region Base64
+
+    private static async Task AddBase64ImageAsync(FileInfo fileInfo, ImageModel imageModel, bool isThumb, int height)
+    {
+        using var magickImage = await ImageDecoder.Base64ToMagickImage(fileInfo.FullName).ConfigureAwait(false);
+        using var b64Stream = new MemoryStream();
+        if (isThumb)
+        {
+            magickImage.Thumbnail(0, height);
+        }
+        else
+        {
+            await magickImage.WriteAsync(b64Stream);
+            b64Stream.Position = 0;
+        }
+        Add(b64Stream, imageModel);
+    }
+
+    #endregion
+    
+    #region Thumbnail
 
     private static async Task<Bitmap?> GetThumbAsync(string path, int height, FileInfo? fileInfo = null)
     {
@@ -184,6 +223,15 @@ public static class ImageHelper
             return null;
         }
     }
+    
+    private static async Task AddThumbAsync(FileInfo fileInfo, ImageModel imageModel, int height)
+    {
+        var thumb = await GetThumbAsync(fileInfo.FullName, height, fileInfo).ConfigureAwait(false);
+        imageModel.Image = thumb;
+        imageModel.PixelWidth = thumb?.PixelSize.Width ?? 0;
+        imageModel.PixelHeight = thumb?.PixelSize.Height ?? 0;
+        imageModel.ImageType = ImageType.Bitmap;
+    }
 
     private static async Task<Bitmap> CreateThumbAsync(IMagickImage magick, string path, int height, FileInfo? fileInfo = null)
     {
@@ -209,7 +257,10 @@ public static class ImageHelper
         memoryStream.Position = 0;
         return WriteableBitmap.Decode(memoryStream);
     }
+    
+    #endregion
 
+    #region Helpers
     
     public static void SetImage(object image, Image imageControl, ImageType imageType)
     {
@@ -217,7 +268,6 @@ public static class ImageHelper
         {
             ImageType.Svg => new SvgImage { Source = SvgSource.Load(image as string) },
             ImageType.Bitmap => image as Bitmap,
-            ImageType.AnimatedBitmap => image as Bitmap,
             _ => imageControl.Source
         };
     }
@@ -228,4 +278,12 @@ public static class ImageHelper
         magickImage.Ping(vm.FileInfo);
         return EXIFHelper.GetImageOrientation(magickImage);
     }
+    
+    public static bool IsAnimated(FileInfo fileInfo)
+    {
+        var frames = ImageFunctionHelper.GetImageFrames(fileInfo.FullName);
+        return frames > 1;
+    }
+    
+    #endregion
 }

+ 1 - 0
src/PicView.Avalonia/Navigation/ErrorHandling.cs

@@ -7,6 +7,7 @@ using PicView.Avalonia.Views.UC;
 using PicView.Core.Calculations;
 using PicView.Core.Config;
 using PicView.Core.Gallery;
+using StartUpMenu = PicView.Avalonia.Views.StartUpMenu;
 
 namespace PicView.Avalonia.Navigation;
 

+ 1 - 1
src/PicView.Avalonia/Navigation/ExifHandling.cs

@@ -118,7 +118,7 @@ public static class ExifHandling
                 }
             }
 
-            if (vm.DpiX is 0 && imageModel.ImageType is ImageType.Bitmap or ImageType.AnimatedBitmap)
+            if (vm.DpiX is 0 && imageModel.ImageType is ImageType.Bitmap or ImageType.AnimatedGif or ImageType.AnimatedWebp)
             {
                 if (imageModel.Image is Bitmap bmp)
                 {

+ 8 - 3
src/PicView.Avalonia/Navigation/ImageIterator.cs

@@ -519,19 +519,24 @@ public sealed class ImageIterator : IDisposable
         _vm.IsLoading = false;
         ExifHandling.SetImageModel(preLoadValue.ImageModel, vm: _vm);
         _vm.ImageSource = preLoadValue.ImageModel.Image;
+        if (preLoadValue.ImageModel.ImageType is ImageType.AnimatedGif or ImageType.AnimatedWebp)
+        {
+            _vm.ImageViewer.MainImage.InitialAnimatedSource = preLoadValue.ImageModel.FileInfo.FullName;
+        }
         _vm.ImageType = preLoadValue.ImageModel.ImageType;
         WindowHelper.SetSize(preLoadValue.ImageModel.PixelWidth, preLoadValue.ImageModel.PixelHeight, preLoadValue.ImageModel.Rotation, _vm);
+        SetTitleHelper.SetTitle(_vm, preLoadValue.ImageModel);
+
         if (_vm.RotationAngle != 0)
         {
             _vm.ImageViewer.Rotate(_vm.RotationAngle);
         }
-        SetTitleHelper.SetTitle(_vm, preLoadValue.ImageModel);
-        _vm.GetIndex = index + 1;
         if (SettingsHelper.Settings.WindowProperties.KeepCentered)
         {
             WindowHelper.CenterWindowOnScreen(false);
         }
-
+        
+        _vm.GetIndex = index + 1;
         if (_vm.SelectedGalleryItemIndex != index)
         {
             _vm.SelectedGalleryItemIndex = index;

+ 2 - 1
src/PicView.Avalonia/Navigation/ImageType.cs

@@ -3,7 +3,8 @@
 public enum ImageType
 {
     Invalid,
-    AnimatedBitmap,
+    AnimatedGif,
+    AnimatedWebp,
     Bitmap,
     Svg,
 }

+ 4 - 0
src/PicView.Avalonia/Navigation/QuickLoad.cs

@@ -27,6 +27,10 @@ public static class QuickLoad
         vm.CurrentView = vm.ImageViewer;
         vm.FileInfo ??= fileInfo;
         var imageModel = await ImageHelper.GetImageModelAsync(fileInfo).ConfigureAwait(false);
+        if (imageModel.ImageType is ImageType.AnimatedGif or ImageType.AnimatedWebp)
+        {
+            vm.ImageViewer.MainImage.InitialAnimatedSource = file;
+        }
         vm.ImageSource = imageModel.Image;
         vm.ImageType = imageModel.ImageType;
         WindowHelper.SetSize(imageModel.PixelWidth, imageModel.PixelHeight, imageModel.Rotation, vm);

+ 1 - 3
src/PicView.Avalonia/UI/SetTitleHelper.cs

@@ -1,8 +1,6 @@
-using Avalonia.Threading;
-using PicView.Avalonia.ImageHandling;
+using PicView.Avalonia.ImageHandling;
 using PicView.Avalonia.ViewModels;
 using PicView.Core.FileHandling;
-using PicView.Core.ImageDecoding;
 using PicView.Core.Localization;
 using PicView.Core.Navigation;
 

+ 1 - 0
src/PicView.Avalonia/UI/StartUpHelper.cs

@@ -9,6 +9,7 @@ using PicView.Avalonia.Views;
 using PicView.Avalonia.Views.UC;
 using PicView.Core.Config;
 using PicView.Core.Localization;
+using StartUpMenu = PicView.Avalonia.Views.StartUpMenu;
 
 namespace PicView.Avalonia.UI;
 

+ 2 - 1
src/PicView.Avalonia/UI/WindowHelper.cs

@@ -546,7 +546,8 @@ public static class WindowHelper
         {
             SaveSize(window);
         }
-        SettingsHelper.Settings.StartUp.LastFile = FileHistoryNavigation.GetLastFile();
+        var vm = window.DataContext as MainViewModel;
+        SettingsHelper.Settings.StartUp.LastFile = vm?.FileInfo?.FullName ?? FileHistoryNavigation.GetLastFile();
         await SettingsHelper.SaveSettingsAsync();
         await KeybindingsHelper.UpdateKeyBindingsFile(); // Save keybindings
         FileDeletionHelper.DeleteTempFiles();

+ 1 - 1
src/PicView.Avalonia/Views/StartUpMenu.axaml

@@ -4,7 +4,7 @@
     d:DesignHeight="450"
     d:DesignWidth="800"
     mc:Ignorable="d"
-    x:Class="PicView.Avalonia.Views.UC.StartUpMenu"
+    x:Class="PicView.Avalonia.Views.StartUpMenu"
     x:DataType="vm:MainViewModel"
     xmlns="https://github.com/avaloniaui"
     xmlns:d="http://schemas.microsoft.com/expression/blend/2008"

+ 1 - 1
src/PicView.Avalonia/Views/StartUpMenu.axaml.cs

@@ -9,7 +9,7 @@ using PicView.Avalonia.UI;
 using PicView.Avalonia.ViewModels;
 using PicView.Core.Calculations;
 
-namespace PicView.Avalonia.Views.UC;
+namespace PicView.Avalonia.Views;
 
 public partial class StartUpMenu : UserControl
 {

+ 32 - 0
src/PicView.Core/ImageDecoding/ImageFunctionHelper.cs

@@ -0,0 +1,32 @@
+using System.Diagnostics;
+using ImageMagick;
+
+namespace PicView.Core.ImageDecoding;
+
+public static class ImageFunctionHelper
+{
+    /// <summary>
+    /// Gets the number of frames in an image.
+    /// </summary>
+    /// <param name="file">The path to the image file.</param>
+    /// <returns>The number of frames in the image. Returns 0 if an error occurs.</returns>
+    /// <remarks>
+    /// This method uses the Magick.NET library to load the image and retrieve the frame count.
+    /// </remarks>
+    public static int GetImageFrames(string file)
+    {
+        try
+        {
+            using var magickImageCollection = new MagickImageCollection(file);
+            return magickImageCollection.Count;
+        }
+        catch (MagickException ex)
+        {
+            #if DEBUG
+            Trace.WriteLine($"{nameof(GetImageFrames)} Exception \n{ex}");
+            #endif
+            
+            return 1;
+        }
+    }
+}

+ 1 - 1
src/PicView.WPF/ChangeImage/QuickLoad.cs

@@ -61,7 +61,7 @@ internal static class QuickLoad
 
         if (fileInfo.Extension.Equals(".gif", StringComparison.OrdinalIgnoreCase))
         {
-            var frames = ImageFunctions.GetImageFrames(fileInfo.FullName);
+            var frames = ImageFunctionHelper.GetImageFrames(fileInfo.FullName);
             if (frames > 1)
             {
                 var uri = new Uri(fileInfo.FullName);

+ 1 - 1
src/PicView.WPF/ChangeImage/UpdateImage.cs

@@ -144,7 +144,7 @@ internal static class UpdateImage
         preLoadValue.FileInfo ??= new FileInfo(Pics[FolderIndex]);
         if (preLoadValue.FileInfo.Extension.Equals(".gif", StringComparison.OrdinalIgnoreCase))
         {
-            var frames = await Task.FromResult(ImageFunctions.GetImageFrames(preLoadValue.FileInfo.FullName))
+            var frames = await Task.FromResult(ImageFunctionHelper.GetImageFrames(preLoadValue.FileInfo.FullName))
                 .ConfigureAwait(false);
             if (frames > 1)
             {

+ 0 - 26
src/PicView.WPF/ImageHandling/ImageFunctions.cs

@@ -196,32 +196,6 @@ internal static class ImageFunctions
         }
     }
 
-    /// <summary>
-    /// Gets the number of frames in an image.
-    /// </summary>
-    /// <param name="file">The path to the image file.</param>
-    /// <returns>The number of frames in the image. Returns 0 if an error occurs.</returns>
-    /// <remarks>
-    /// This method uses the Magick.NET library to load the image and retrieve the frame count.
-    /// </remarks>
-    public static int GetImageFrames(string file)
-    {
-        try
-        {
-            // Using statement ensures proper disposal of resources.
-            using var magick = new MagickImageCollection(file);
-            return magick.Count;
-        }
-        catch (MagickException ex)
-        {
-            // Log the exception for debugging purposes.
-            Trace.WriteLine($"{nameof(GetImageFrames)} Exception \n{ex}");
-
-            // Return 0 in case of an error.
-            return 0;
-        }
-    }
-
     internal static RenderTargetBitmap? ImageErrorMessage()
     {
         var brokenBitmapImage = (DrawingImage)Application.Current.Resources["BrokenDrawingImage"];

+ 1 - 1
src/PicView.sln

@@ -19,7 +19,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PicView.Windows", "PicView.
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PicView.MacOS", "PicView.MacOS\PicView.MacOS.csproj", "{FEAC727E-1102-4F11-A747-5A5789FBBF81}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PicView.Tests", "PicView.Tests\PicView.Tests.csproj", "{E9901309-A077-42CF-957C-9E737DC9731C}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PicView.Tests", "PicView.Tests\PicView.Tests.csproj", "{E9901309-A077-42CF-957C-9E737DC9731C}"
 EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution