diff --git a/Gml.Launcher.sln b/Gml.Launcher.sln index f469eff..d85bee9 100644 --- a/Gml.Launcher.sln +++ b/Gml.Launcher.sln @@ -31,6 +31,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gml.Web.Api.Domains", "src\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gml.Core.Interfaces", "src\Gml.Client\src\Gml.Core.Interfaces\Gml.Core.Interfaces.csproj", "{AE697645-C3EB-4FC8-AAA6-932B9751D348}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "L1.Avalonia.Gif", "src\L1.Avalonia.Gif\L1.Avalonia.Gif.csproj", "{19B15268-CF4D-4571-8DF3-6EBC586BB856}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "launcher", "launcher", "{48DF3E50-4AB6-4569-9C79-E8FAC444434E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -65,6 +69,10 @@ Global {AE697645-C3EB-4FC8-AAA6-932B9751D348}.Debug|Any CPU.Build.0 = Debug|Any CPU {AE697645-C3EB-4FC8-AAA6-932B9751D348}.Release|Any CPU.ActiveCfg = Release|Any CPU {AE697645-C3EB-4FC8-AAA6-932B9751D348}.Release|Any CPU.Build.0 = Release|Any CPU + {19B15268-CF4D-4571-8DF3-6EBC586BB856}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19B15268-CF4D-4571-8DF3-6EBC586BB856}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19B15268-CF4D-4571-8DF3-6EBC586BB856}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19B15268-CF4D-4571-8DF3-6EBC586BB856}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -73,12 +81,13 @@ Global SolutionGuid = {E0375879-A52D-4581-81EF-7A27063F494F} EndGlobalSection GlobalSection(NestedProjects) = preSolution - {F2771E71-C782-4713-862F-217694DDDFF5} = {662268E9-BD01-43C7-924B-F4F285C8D391} {49013DAA-207C-4542-8F85-671FB45797F4} = {662268E9-BD01-43C7-924B-F4F285C8D391} {34519B31-294B-4868-BFDB-817E550A54EC} = {53569526-A495-4E84-ADF3-83D2188318AB} {B8E0451C-3C44-470B-9853-C7D0A3648C20} = {662268E9-BD01-43C7-924B-F4F285C8D391} {20707D1F-FF51-4E7B-BF99-2B87065AFDC7} = {662268E9-BD01-43C7-924B-F4F285C8D391} {88B821C7-B895-4CB0-832A-5B66138B6AF2} = {662268E9-BD01-43C7-924B-F4F285C8D391} {AE697645-C3EB-4FC8-AAA6-932B9751D348} = {662268E9-BD01-43C7-924B-F4F285C8D391} + {19B15268-CF4D-4571-8DF3-6EBC586BB856} = {662268E9-BD01-43C7-924B-F4F285C8D391} + {F2771E71-C782-4713-862F-217694DDDFF5} = {48DF3E50-4AB6-4569-9C79-E8FAC444434E} EndGlobalSection EndGlobal diff --git a/src/Gml.Client b/src/Gml.Client index 20badc0..6dd49d9 160000 --- a/src/Gml.Client +++ b/src/Gml.Client @@ -1 +1 @@ -Subproject commit 20badc088bedc392a39b747b35abf7c4cac522e1 +Subproject commit 6dd49d978a475634c0f26fe8b650562cebe5f06b diff --git a/src/Gml.Launcher/Assets/Images/document.svg b/src/Gml.Launcher/Assets/Images/document.svg index bc3ecd7..e29421c 100644 --- a/src/Gml.Launcher/Assets/Images/document.svg +++ b/src/Gml.Launcher/Assets/Images/document.svg @@ -1,4 +1,4 @@ -ф + diff --git a/src/Gml.Launcher/Core/Converters/AsyncStreamToImageLoader.cs b/src/Gml.Launcher/Core/Converters/AsyncStreamToImageLoader.cs index 5657845..bac3861 100644 --- a/src/Gml.Launcher/Core/Converters/AsyncStreamToImageLoader.cs +++ b/src/Gml.Launcher/Core/Converters/AsyncStreamToImageLoader.cs @@ -5,6 +5,7 @@ using Avalonia; using Avalonia.Media.Imaging; using Gml.Launcher.Views.Components; +using L1.Avalonia.Gif.Decoding; using Sentry; namespace Gml.Launcher.Core.Converters; @@ -27,6 +28,7 @@ private static async void OnSourceChanged(BackgroundComponent sender, AvaloniaPr { try { + sender.Classes.Clear(); var url = args.GetNewValue(); if (string.IsNullOrEmpty(url)) @@ -41,17 +43,30 @@ private static async void OnSourceChanged(BackgroundComponent sender, AvaloniaPr return; } - var fileName = Path.Combine(TempPath, Path.GetFileName(url)); + var fileName = new FileInfo(Path.Combine(TempPath, Path.GetFileName(url))); - if (!File.Exists(fileName)) + if (!fileName.Exists || fileName.Length == 0) { using var client = new HttpClient(); var response = await client.GetByteArrayAsync(url); using var stream = new MemoryStream(response); - await ConvertStreamToFile(stream, fileName); + await ConvertStreamToFile(stream, fileName.FullName); + } + + + var fileStream = File.OpenRead(fileName.FullName); + + if (GifDecoder.IsGifStream(fileStream)) + { + sender.Classes.Add("Gif"); + sender.SourceStream = fileStream; + } + else + { + sender.Classes.Add("Image"); + sender.Source = new Bitmap(fileStream); } - sender.Source = new Bitmap(File.OpenRead(fileName)); } catch (Exception exception) { @@ -78,9 +93,9 @@ private static bool ValidateUrl(string url) { return Uri.TryCreate(url, UriKind.Absolute, out var uriResult) && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps) - && Path.GetFileName(url) is {} fileName && Guid.TryParse(fileName, out _); + && Path.GetFileName(url) is { } fileName && Guid.TryParse(fileName, out _); } public static void SetSource(BackgroundComponent obj, string value) => obj.SetValue(SourceProperty, value); public static string GetSource(BackgroundComponent obj) => obj.GetValue(SourceProperty); -} +} \ No newline at end of file diff --git a/src/Gml.Launcher/Gml.Launcher.csproj b/src/Gml.Launcher/Gml.Launcher.csproj index 579313d..b162cbb 100644 --- a/src/Gml.Launcher/Gml.Launcher.csproj +++ b/src/Gml.Launcher/Gml.Launcher.csproj @@ -67,6 +67,7 @@ + diff --git a/src/Gml.Launcher/ViewModels/Pages/LoginPageViewModel.cs b/src/Gml.Launcher/ViewModels/Pages/LoginPageViewModel.cs index d9d7ce0..acd9def 100644 --- a/src/Gml.Launcher/ViewModels/Pages/LoginPageViewModel.cs +++ b/src/Gml.Launcher/ViewModels/Pages/LoginPageViewModel.cs @@ -22,7 +22,7 @@ public class LoginPageViewModel : PageViewModelBase private string _login = string.Empty; private bool _isProcessing = false; private string _password = string.Empty; - private readonly IScreen _screen; + private readonly MainWindowViewModel _screen; private readonly IObservable _onClosed; private readonly IStorageService _storageService; private readonly IGmlClientManager _gmlClientManager; @@ -70,7 +70,7 @@ internal LoginPageViewModel(IScreen screen, ISystemService? systemService = null, ILocalizationService? localizationService = null) : base(screen, localizationService) { - _screen = screen; + _screen = (MainWindowViewModel)screen; _onClosed = onClosed; _storageService = storageService @@ -85,18 +85,27 @@ internal LoginPageViewModel(IScreen screen, ?? Locator.Current.GetService() ?? throw new ServiceNotFoundException(typeof(IGmlClientManager)); + _screen.OnClosed.Subscribe(DisposeConnections); LoginCommand = ReactiveCommand.CreateFromTask(OnAuth); RxApp.MainThreadScheduler.Schedule(CheckAuth); } + private void DisposeConnections(bool isClosed) + { + _gmlClientManager.Dispose(); + } + private async void CheckAuth() { var authUser = await _storageService.GetAsync(StorageConstants.User); if (authUser is { IsAuth: true }) + { _screen.Router.Navigate.Execute(new OverviewPageViewModel(_screen, authUser, _onClosed)); + await _gmlClientManager.OpenServerConnection(authUser); + } } private async Task OnAuth(CancellationToken arg) diff --git a/src/Gml.Launcher/Views/Components/BackgroundComponent.axaml b/src/Gml.Launcher/Views/Components/BackgroundComponent.axaml index 961758c..dcab09b 100644 --- a/src/Gml.Launcher/Views/Components/BackgroundComponent.axaml +++ b/src/Gml.Launcher/Views/Components/BackgroundComponent.axaml @@ -1,12 +1,13 @@  + xmlns:controls="using:Gml.Launcher.Views.Components" + xmlns:gif="clr-namespace:L1.Avalonia.Gif;assembly=L1.Avalonia.Gif"> - diff --git a/src/Gml.Launcher/Views/Components/BackgroundComponent.axaml.cs b/src/Gml.Launcher/Views/Components/BackgroundComponent.axaml.cs index 1504b5a..ea1db3b 100644 --- a/src/Gml.Launcher/Views/Components/BackgroundComponent.axaml.cs +++ b/src/Gml.Launcher/Views/Components/BackgroundComponent.axaml.cs @@ -1,8 +1,7 @@ -using Avalonia; -using Avalonia.Controls; +using System.IO; +using Avalonia; using Avalonia.Controls.Primitives; using Avalonia.Media; -using Avalonia.Media.Imaging; namespace Gml.Launcher.Views.Components; @@ -12,11 +11,18 @@ public class BackgroundComponent : TemplatedControl public static readonly StyledProperty SourceProperty = AvaloniaProperty.Register( nameof(Source)); + public static readonly StyledProperty SourceStreamProperty = AvaloniaProperty.Register( + nameof(SourceStream)); + public IImage? Source { get => GetValue(SourceProperty); set => SetValue(SourceProperty, value); } - + public Stream? SourceStream + { + get => GetValue(SourceStreamProperty); + set => SetValue(SourceStreamProperty, value); + } } diff --git a/src/Gml.Launcher/Views/MainWindow.axaml b/src/Gml.Launcher/Views/MainWindow.axaml index 4bc71c8..23798a3 100644 --- a/src/Gml.Launcher/Views/MainWindow.axaml +++ b/src/Gml.Launcher/Views/MainWindow.axaml @@ -40,7 +40,7 @@ - + diff --git a/src/Gml.Launcher/Views/Pages/SettingsPageView.axaml b/src/Gml.Launcher/Views/Pages/SettingsPageView.axaml index 66f13a5..5c0a29e 100644 --- a/src/Gml.Launcher/Views/Pages/SettingsPageView.axaml +++ b/src/Gml.Launcher/Views/Pages/SettingsPageView.axaml @@ -133,24 +133,24 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/src/L1.Avalonia.Gif/AvaloniaGif.csproj b/src/L1.Avalonia.Gif/AvaloniaGif.csproj new file mode 100644 index 0000000..12aec6a --- /dev/null +++ b/src/L1.Avalonia.Gif/AvaloniaGif.csproj @@ -0,0 +1,16 @@ + + + net7.0 + 11 + true + enable + + + + + + + + + + diff --git a/src/L1.Avalonia.Gif/BgWorkerCommand.cs b/src/L1.Avalonia.Gif/BgWorkerCommand.cs new file mode 100644 index 0000000..8714b78 --- /dev/null +++ b/src/L1.Avalonia.Gif/BgWorkerCommand.cs @@ -0,0 +1,9 @@ +namespace L1.Avalonia.Gif; + +internal enum BgWorkerCommand +{ + Null, + Play, + Pause, + Dispose +} diff --git a/src/L1.Avalonia.Gif/BgWorkerState.cs b/src/L1.Avalonia.Gif/BgWorkerState.cs new file mode 100644 index 0000000..0a26382 --- /dev/null +++ b/src/L1.Avalonia.Gif/BgWorkerState.cs @@ -0,0 +1,11 @@ +namespace L1.Avalonia.Gif; + +internal enum BgWorkerState +{ + Null, + Start, + Running, + Paused, + Complete, + Dispose +} diff --git a/src/L1.Avalonia.Gif/Decoding/BlockTypes.cs b/src/L1.Avalonia.Gif/Decoding/BlockTypes.cs new file mode 100644 index 0000000..f3c752d --- /dev/null +++ b/src/L1.Avalonia.Gif/Decoding/BlockTypes.cs @@ -0,0 +1,9 @@ +namespace L1.Avalonia.Gif.Decoding; + +internal enum BlockTypes +{ + EMPTY = 0, + EXTENSION = 0x21, + IMAGE_DESCRIPTOR = 0x2C, + TRAILER = 0x3B +} diff --git a/src/L1.Avalonia.Gif/Decoding/ExtensionType.cs b/src/L1.Avalonia.Gif/Decoding/ExtensionType.cs new file mode 100644 index 0000000..b8379c7 --- /dev/null +++ b/src/L1.Avalonia.Gif/Decoding/ExtensionType.cs @@ -0,0 +1,7 @@ +namespace L1.Avalonia.Gif.Decoding; + +internal enum ExtensionType +{ + GRAPHICS_CONTROL = 0xF9, + APPLICATION = 0xFF +} diff --git a/src/L1.Avalonia.Gif/Decoding/FrameDisposal.cs b/src/L1.Avalonia.Gif/Decoding/FrameDisposal.cs new file mode 100644 index 0000000..1efad2b --- /dev/null +++ b/src/L1.Avalonia.Gif/Decoding/FrameDisposal.cs @@ -0,0 +1,9 @@ +namespace L1.Avalonia.Gif.Decoding; + +public enum FrameDisposal +{ + Unknown = 0, + Leave = 1, + Background = 2, + Restore = 3 +} diff --git a/src/L1.Avalonia.Gif/Decoding/GifColor.cs b/src/L1.Avalonia.Gif/Decoding/GifColor.cs new file mode 100644 index 0000000..e3863d1 --- /dev/null +++ b/src/L1.Avalonia.Gif/Decoding/GifColor.cs @@ -0,0 +1,31 @@ +using System.Runtime.InteropServices; + +namespace L1.Avalonia.Gif.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; + + /// + /// A struct that represents a ARGB color and is aligned as + /// a BGRA bytefield in memory. + /// + /// Red + /// Green + /// Blue + /// Alpha + public GifColor(byte r, byte g, byte b, byte a = byte.MaxValue) + { + A = a; + R = r; + G = g; + B = b; + } +} diff --git a/src/L1.Avalonia.Gif/Decoding/GifDecoder.cs b/src/L1.Avalonia.Gif/Decoding/GifDecoder.cs new file mode 100644 index 0000000..9eb920e --- /dev/null +++ b/src/L1.Avalonia.Gif/Decoding/GifDecoder.cs @@ -0,0 +1,668 @@ +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using Avalonia; +using Avalonia.Media.Imaging; +using static L1.Avalonia.Gif.Extensions.StreamExtensions; + +namespace L1.Avalonia.Gif.Decoding; + +public sealed class GifDecoder : IDisposable +{ + private static readonly ReadOnlyMemory G87AMagic + = Encoding.ASCII.GetBytes("GIF87a").AsMemory(); + + private static readonly ReadOnlyMemory G89AMagic + = Encoding.ASCII.GetBytes("GIF89a").AsMemory(); + + private static readonly ReadOnlyMemory NetscapeMagic + = Encoding.ASCII.GetBytes("NETSCAPE2.0").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 static readonly (int Start, int Step)[] Pass = + { + (0, 8), + (4, 8), + (2, 4), + (1, 2) + }; + + // private ulong _globalColorTable; + private readonly int _backBufferBytes; + private readonly CancellationToken _currentCtsToken; + + private readonly Stream _fileStream; + private readonly bool _hasFrameBackups; + + public readonly List Frames = new(); + private byte[] _backupFrameIndexBuf; + private GifColor[] _bitmapBackBuffer; + + private int _gctSize, _bgIndex, _prevFrame = -1, _backupFrame = -1; + private bool _gctUsed; + + private GifRect _gifDimensions; + private volatile bool _hasNewFrame; + private byte[] _indexBuf; + private byte[] _pixelStack; + + private short[] _prefixBuf; + private byte[] _suffixBuf; + + public GifDecoder(Stream fileStream, CancellationToken currentCtsToken) + { + _fileStream = fileStream; + _currentCtsToken = currentCtsToken; + + ProcessHeaderData(); + ProcessFrameData(); + + 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 GifHeader Header { get; private set; } + + public PixelSize Size => new(Header.Dimensions.Width, Header.Dimensions.Height); + + 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) + { + return x + y * _gifDimensions.Width; + } + + private void ClearImage() + { + Array.Fill(_bitmapBackBuffer, TransparentColor); + //ClearArea(_gifDimensions); + + _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 (var 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.Shared.Rent(MaxTempBuf); + + var curFrame = Frames[idx]; + DecompressFrameToIndexBuffer(curFrame, _indexBuf, tmpB); + + if (_hasFrameBackups & curFrame.ShouldBackup) + { + 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.Shared.Return(tmpB); + } + + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DrawFrame(GifFrame curFrame, Memory frameIndexSpan) + { + var activeColorTable = + curFrame.IsLocalColorTableUsed ? curFrame.LocalColorTable : Header.GlobarColorTable; + + 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) + { + var 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); + } + + //for (var row = 0; row < cH; row++) + 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); + 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) + { + 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 indexSpan, byte[] tempBuf) + { + _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) + ThrowException(); + } + + while (code >= clear) + { + if (code >= MaxBits || code == _prefixBuf[code]) + ThrowException(); + + _pixelStack[top++] = _suffixBuf[code]; + code = _prefixBuf[code]; + + if (top == MaxBits) + ThrowException(); + } + + 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 + + void ThrowException() + { + throw new LzwDecompressionException(); + } + } + + /// + /// Directly copies the struct array to a bitmap IntPtr. + /// + 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; + } + } + + public static bool IsGifStream(Stream stream) + { + var tmpB = ArrayPool.Shared.Rent(MaxTempBuf); + var tempBuf = tmpB.AsSpan(); + + var _ = stream.Read(tmpB, 0, 6); + stream.Position = 0; + + return tempBuf[..3].SequenceEqual(G87AMagic[..3].Span); + } + + /// + /// Processes GIF Header. + /// + private void ProcessHeaderData() + { + var str = _fileStream; + var tmpB = ArrayPool.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, + HasGlobalColorTable = _gctUsed, + // GlobalColorTableCacheID = _globalColorTable, + GlobarColorTable = + _gctUsed ? ProcessColorTable(ref str, tmpB, _gctSize) : Array.Empty(), + GlobalColorTableSize = _gctSize, + BackgroundColorIndex = _bgIndex, + HeaderSize = _fileStream.Position + }; + + ArrayPool.Shared.Return(tmpB); + } + + /// + /// Parses colors from file stream to target color table. + /// + 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; + } + + /// + /// Parses screen and other GIF descriptors. + /// + 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); + _bgIndex = _fileStream.ReadByteS(tempBuf); + + _gifDimensions = new GifRect(0, 0, width, height); + _fileStream.Skip(1); + } + + /// + /// Parses all frame data. + /// + private void ProcessFrameData() + { + _fileStream.Position = Header.HeaderSize; + + var tempBuf = ArrayPool.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.IMAGE_DESCRIPTOR: + 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.Shared.Return(tempBuf); + } + + /// + /// Parses GIF Image Descriptor Block. + /// + 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()); + } + + /// + /// Parses GIF Extension Blocks. + /// + private void ProcessExtensions(ref int curFrame, byte[] tempBuf) + { + var extType = (ExtensionType)_fileStream.ReadByteS(tempBuf); + + switch (extType) + { + case ExtensionType.GRAPHICS_CONTROL: + + _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)); + + Header.Iterations = iterationCount; + } + else + { + _fileStream.SkipBlocks(tempBuf); + } + + break; + + default: + _fileStream.SkipBlocks(tempBuf); + break; + } + } +} diff --git a/src/L1.Avalonia.Gif/Decoding/GifFrame.cs b/src/L1.Avalonia.Gif/Decoding/GifFrame.cs new file mode 100644 index 0000000..5b504dc --- /dev/null +++ b/src/L1.Avalonia.Gif/Decoding/GifFrame.cs @@ -0,0 +1,14 @@ +namespace L1.Avalonia.Gif.Decoding; + +public class GifFrame +{ + public GifRect Dimensions; + public TimeSpan FrameDelay; + public FrameDisposal FrameDisposalMethod; + public bool HasTransparency, IsInterlaced, IsLocalColorTableUsed; + public GifColor[] LocalColorTable; + public int LZWMinCodeSize, LocalColorTableSize; + public long LZWStreamPosition; + public bool ShouldBackup; + public byte TransparentColorIndex; +} diff --git a/src/L1.Avalonia.Gif/Decoding/GifHeader.cs b/src/L1.Avalonia.Gif/Decoding/GifHeader.cs new file mode 100644 index 0000000..cc2c5fa --- /dev/null +++ b/src/L1.Avalonia.Gif/Decoding/GifHeader.cs @@ -0,0 +1,15 @@ +namespace L1.Avalonia.Gif.Decoding; + +public class GifHeader +{ + private GifColor[] _globarColorTable; + public int BackgroundColorIndex; + public GifRect Dimensions; + public ulong GlobalColorTableCacheID; + public int GlobalColorTableSize; + public GifColor[] GlobarColorTable; + public bool HasGlobalColorTable; + public long HeaderSize; + public GifRepeatBehavior IterationCount; + internal int Iterations = -1; +} diff --git a/src/L1.Avalonia.Gif/Decoding/GifRect.cs b/src/L1.Avalonia.Gif/Decoding/GifRect.cs new file mode 100644 index 0000000..2a42737 --- /dev/null +++ b/src/L1.Avalonia.Gif/Decoding/GifRect.cs @@ -0,0 +1,45 @@ +namespace L1.Avalonia.Gif.Decoding; + +public readonly struct GifRect +{ + public int X { get; } + public int Y { get; } + public int Width { get; } + public int Height { get; } + public int TotalPixels { get; } + + public GifRect(int x, int y, int width, int height) + { + X = x; + Y = y; + Width = width; + Height = height; + TotalPixels = 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()); + } +} diff --git a/src/L1.Avalonia.Gif/Decoding/GifRepeatBehavior.cs b/src/L1.Avalonia.Gif/Decoding/GifRepeatBehavior.cs new file mode 100644 index 0000000..08e8302 --- /dev/null +++ b/src/L1.Avalonia.Gif/Decoding/GifRepeatBehavior.cs @@ -0,0 +1,7 @@ +namespace L1.Avalonia.Gif.Decoding; + +public class GifRepeatBehavior +{ + public bool LoopForever { get; set; } + public int? Count { get; set; } +} diff --git a/src/L1.Avalonia.Gif/Decoding/InvalidGifStreamException.cs b/src/L1.Avalonia.Gif/Decoding/InvalidGifStreamException.cs new file mode 100644 index 0000000..fddcdd2 --- /dev/null +++ b/src/L1.Avalonia.Gif/Decoding/InvalidGifStreamException.cs @@ -0,0 +1,23 @@ +using System.Runtime.Serialization; + +namespace L1.Avalonia.Gif.Decoding; + +[Serializable] +public class InvalidGifStreamException : Exception +{ + public InvalidGifStreamException() + { + } + + public InvalidGifStreamException(string message) : base(message) + { + } + + public InvalidGifStreamException(string message, Exception innerException) : base(message, innerException) + { + } + + protected InvalidGifStreamException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } +} diff --git a/src/L1.Avalonia.Gif/Decoding/LzwDecompressionException.cs b/src/L1.Avalonia.Gif/Decoding/LzwDecompressionException.cs new file mode 100644 index 0000000..1453bda --- /dev/null +++ b/src/L1.Avalonia.Gif/Decoding/LzwDecompressionException.cs @@ -0,0 +1,23 @@ +using System.Runtime.Serialization; + +namespace L1.Avalonia.Gif.Decoding; + +[Serializable] +public class LzwDecompressionException : Exception +{ + public LzwDecompressionException() + { + } + + public LzwDecompressionException(string message) : base(message) + { + } + + public LzwDecompressionException(string message, Exception innerException) : base(message, innerException) + { + } + + protected LzwDecompressionException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } +} diff --git a/src/L1.Avalonia.Gif/Extensions/StreamExtensions.cs b/src/L1.Avalonia.Gif/Extensions/StreamExtensions.cs new file mode 100644 index 0000000..9d3d3cc --- /dev/null +++ b/src/L1.Avalonia.Gif/Extensions/StreamExtensions.cs @@ -0,0 +1,81 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace L1.Avalonia.Gif.Extensions; + +[DebuggerStepThrough] +internal static class StreamExtensions +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ushort SpanToShort(Span b) + { + return (ushort)(b[0] | (b[1] << 8)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Skip(this Stream stream, long count) + { + stream.Position += count; + } + + /// + /// Read a Gif block from stream while advancing the position. + /// + [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; + } + + /// + /// Skips GIF blocks until it encounters an empty block. + /// + [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); + } + + /// + /// Read a from stream by providing a temporary buffer. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ushort ReadUShortS(this Stream stream, byte[] tempBuf) + { + stream.Read(tempBuf, 0, 2); + return SpanToShort(tempBuf); + } + + /// + /// Read a from stream by providing a temporary buffer. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte ReadByteS(this Stream stream, byte[] tempBuf) + { + stream.Read(tempBuf, 0, 1); + var finalVal = tempBuf[0]; + return finalVal; + } +} diff --git a/src/L1.Avalonia.Gif/GifImage.cs b/src/L1.Avalonia.Gif/GifImage.cs new file mode 100644 index 0000000..bbc6a77 --- /dev/null +++ b/src/L1.Avalonia.Gif/GifImage.cs @@ -0,0 +1,215 @@ +using System.Diagnostics; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Threading; + +namespace L1.Avalonia.Gif; + +public class GifImage : Control +{ + public static readonly StyledProperty SourceUriRawProperty = + AvaloniaProperty.Register("SourceUriRaw"); + + public static readonly StyledProperty SourceUriProperty = + AvaloniaProperty.Register("SourceUri"); + + public static readonly StyledProperty SourceStreamProperty = + AvaloniaProperty.Register("SourceStream"); + + public static readonly StyledProperty IterationCountProperty = + AvaloniaProperty.Register("IterationCount", IterationCount.Infinite); + + public static readonly StyledProperty AutoStartProperty = + AvaloniaProperty.Register("AutoStart"); + + public static readonly StyledProperty StretchDirectionProperty = + AvaloniaProperty.Register("StretchDirection"); + + public static readonly StyledProperty StretchProperty = + AvaloniaProperty.Register("Stretch"); + + private bool _hasNewSource; + private object? _newSource; + private Stopwatch _stopwatch; + + private RenderTargetBitmap backingRTB; + + private GifInstance gifInstance; + + static GifImage() + { + SourceUriRawProperty.Changed.Subscribe(SourceChanged); + SourceUriProperty.Changed.Subscribe(SourceChanged); + SourceStreamProperty.Changed.Subscribe(SourceChanged); + IterationCountProperty.Changed.Subscribe(IterationCountChanged); + AutoStartProperty.Changed.Subscribe(AutoStartChanged); + AffectsRender(SourceStreamProperty, SourceUriProperty, SourceUriRawProperty, StretchProperty); + AffectsArrange(SourceStreamProperty, SourceUriProperty, SourceUriRawProperty, StretchProperty); + AffectsMeasure(SourceStreamProperty, SourceUriProperty, SourceUriRawProperty, StretchProperty); + } + + public string SourceUriRaw + { + get => GetValue(SourceUriRawProperty); + set => SetValue(SourceUriRawProperty, value); + } + + public Uri SourceUri + { + get => GetValue(SourceUriProperty); + set => SetValue(SourceUriProperty, value); + } + + public Stream SourceStream + { + get => GetValue(SourceStreamProperty); + set => SetValue(SourceStreamProperty, value); + } + + public IterationCount IterationCount + { + get => GetValue(IterationCountProperty); + set => SetValue(IterationCountProperty, value); + } + + public bool AutoStart + { + get => GetValue(AutoStartProperty); + set => SetValue(AutoStartProperty, value); + } + + public StretchDirection StretchDirection + { + get => GetValue(StretchDirectionProperty); + set => SetValue(StretchDirectionProperty, value); + } + + public Stretch Stretch + { + get => GetValue(StretchProperty); + set => SetValue(StretchProperty, value); + } + + private static void AutoStartChanged(AvaloniaPropertyChangedEventArgs e) + { + var image = e.Sender as GifImage; + if (image == null) + return; + } + + private static void IterationCountChanged(AvaloniaPropertyChangedEventArgs e) + { + var image = e.Sender as GifImage; + if (image is null || e.NewValue is not IterationCount iterationCount) + return; + + image.IterationCount = iterationCount; + } + + public override void Render(DrawingContext context) + { + Dispatcher.UIThread.Post(InvalidateMeasure, DispatcherPriority.Background); + Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background); + + if (_hasNewSource) + { + StopAndDispose(); + gifInstance = new GifInstance(_newSource); + gifInstance.IterationCount = IterationCount; + backingRTB = new RenderTargetBitmap(gifInstance.GifPixelSize, new Vector(96, 96)); + _hasNewSource = false; + + _stopwatch ??= new Stopwatch(); + _stopwatch.Reset(); + + + return; + } + + if (gifInstance is null || (gifInstance.CurrentCts?.IsCancellationRequested ?? true)) return; + + if (!_stopwatch.IsRunning) _stopwatch.Start(); + + var currentFrame = gifInstance.ProcessFrameTime(_stopwatch.Elapsed); + + if (currentFrame is { } source && backingRTB is not null) + { + using var ctx = backingRTB.CreateDrawingContext(); + var ts = new Rect(source.Size); + ctx.DrawImage(source, ts, ts); + // ctx.DrawImage(source.PlatformImpl, 1, ts, ts); + } + + if (backingRTB is not null && Bounds.Width > 0 && Bounds.Height > 0) + { + var viewPort = new Rect(Bounds.Size); + var sourceSize = backingRTB.Size; + + var scale = Stretch.CalculateScaling(Bounds.Size, sourceSize, StretchDirection); + var scaledSize = sourceSize * scale; + var destRect = viewPort + .CenterRect(new Rect(scaledSize)) + .Intersect(viewPort); + + var sourceRect = new Rect(sourceSize) + .CenterRect(new Rect(destRect.Size / scale)); + + var interpolationMode = RenderOptions.GetBitmapInterpolationMode(this); + + context.DrawImage(backingRTB, sourceRect, destRect); + } + } + + /// + /// Measures the control. + /// + /// The available size. + /// The desired size of the control. + protected override Size MeasureOverride(Size availableSize) + { + var source = backingRTB; + var result = new Size(); + + if (source != null) result = Stretch.CalculateSize(availableSize, source.Size, StretchDirection); + + return result; + } + + /// + protected override Size ArrangeOverride(Size finalSize) + { + var source = backingRTB; + + if (source != null) + { + var sourceSize = source.Size; + var result = Stretch.CalculateSize(finalSize, sourceSize); + return result; + } + + return new Size(); + } + + public void StopAndDispose() + { + gifInstance?.Dispose(); + backingRTB?.Dispose(); + } + + private static void SourceChanged(AvaloniaPropertyChangedEventArgs e) + { + var image = e.Sender as GifImage; + + if (image == null) + return; + + if (e.NewValue is null) return; + + image._hasNewSource = true; + image._newSource = e.NewValue; + Dispatcher.UIThread.Post(image.InvalidateVisual, DispatcherPriority.Background); + } +} diff --git a/src/L1.Avalonia.Gif/GifInstance.cs b/src/L1.Avalonia.Gif/GifInstance.cs new file mode 100644 index 0000000..9351759 --- /dev/null +++ b/src/L1.Avalonia.Gif/GifInstance.cs @@ -0,0 +1,151 @@ +using Avalonia; +using Avalonia.Animation; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using L1.Avalonia.Gif.Decoding; + +namespace L1.Avalonia.Gif +{ + public class GifInstance : IDisposable + { + private readonly List _colorTableIdList; + private readonly List _frameTimes; + private readonly GifDecoder _gifDecoder; + private readonly WriteableBitmap _targetBitmap; + private int _currentFrameIndex; + private uint _iterationCount; + private TimeSpan _totalTime; + + 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); + + // Save the color table cache ID's to refresh them on cache while + // // the image is either stopped/paused. + // _colorTableIdList = _gifDecoder.Frames + // .Where(p => p.IsLocalColorTableUsed) + // .Select(p => p.LocalColorTableCacheID) + // .ToList(); + + // if (_gifDecoder.Header.HasGlobalColorTable) + // _colorTableIdList.Add(_gifDecoder.Header.GlobalColorTableCacheID); + } + + public IterationCount IterationCount { get; set; } + public bool AutoStart { get; private set; } = true; + + public CancellationTokenSource CurrentCts { get; } + + public int GifFrameCount => _frameTimes.Count; + + public PixelSize GifPixelSize { get; } + + public void Dispose() + { + CurrentCts.Cancel(); + _targetBitmap?.Dispose(); + } + + 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; + } + + [CanBeNull] + public WriteableBitmap ProcessFrameTime(TimeSpan stopwatchElapsed) + { + if (!IterationCount.IsInfinite && _iterationCount > IterationCount.Value) return null; + + if (CurrentCts.IsCancellationRequested) 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) + { + _gifDecoder.RenderFrame(frameIndex, _targetBitmap); + _currentFrameIndex = frameIndex; + + return _targetBitmap; + } + } +} + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.Property | + AttributeTargets.Delegate | AttributeTargets.Field)] +public sealed class CanBeNullAttribute : Attribute +{ +} diff --git a/src/L1.Avalonia.Gif/InvalidGifStreamException.cs b/src/L1.Avalonia.Gif/InvalidGifStreamException.cs new file mode 100644 index 0000000..0e1c9e4 --- /dev/null +++ b/src/L1.Avalonia.Gif/InvalidGifStreamException.cs @@ -0,0 +1,23 @@ +using System.Runtime.Serialization; + +namespace L1.Avalonia.Gif; + +[Serializable] +internal class InvalidGifStreamException : Exception +{ + public InvalidGifStreamException() + { + } + + public InvalidGifStreamException(string message) : base(message) + { + } + + public InvalidGifStreamException(string message, Exception innerException) : base(message, innerException) + { + } + + protected InvalidGifStreamException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } +} diff --git a/src/L1.Avalonia.Gif/L1.Avalonia.Gif.csproj b/src/L1.Avalonia.Gif/L1.Avalonia.Gif.csproj new file mode 100644 index 0000000..d893a7c --- /dev/null +++ b/src/L1.Avalonia.Gif/L1.Avalonia.Gif.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + +