Selaa lähdekoodia

Add progressive image loading, refactor, improve image loading.

Ruben 7 kuukautta sitten
vanhempi
sitoutus
c75ee6426e

+ 15 - 2
src/PicView.Avalonia/ImageHandling/GetThumbnails.cs

@@ -1,4 +1,5 @@
-using Avalonia.Media.Imaging;
+using System.Diagnostics;
+using Avalonia.Media.Imaging;
 using ImageMagick;
 using PicView.Core.FileHandling;
 
@@ -35,7 +36,19 @@ public static class GetThumbnails
     public static WriteableBitmap? GetExifThumb(string path)
     {
         using var magick = new MagickImage();
-        magick.Ping(path);
+        try
+        {
+            magick.Ping(path);
+        }
+        catch (Exception e)
+        {
+#if DEBUG
+            Trace.WriteLine(
+                $"\n{nameof(GetExifThumb)} ping exception: \n{e.Message}\n{e.StackTrace}");
+#endif
+            return null;
+        }
+
         var profile = magick.GetExifProfile();
         var thumbnail = profile?.CreateThumbnail();
         return thumbnail?.ToWriteableBitmap();

+ 17 - 10
src/PicView.Avalonia/Navigation/ImageIterator.cs

@@ -602,12 +602,14 @@ public class ImageIterator : IAsyncDisposable
     /// </summary>
     /// <param name="index">The index to iterate to.</param>
     /// <param name="cts">The cancellation token source.</param>
-    /// <returns>A <see cref="Task" /> that represents the asynchronous operation.</returns>
     public async Task IterateToIndex(int index, CancellationTokenSource cts)
     {
         if (index < 0 || index >= ImagePaths.Count)
         {
-            ErrorHandling.ShowStartUpMenu(_vm);
+            // Invalid index. Probably a race condition? Do nothing and report
+#if DEBUG
+            Trace.WriteLine($"Invalid index {index} in {nameof(ImageIterator)}:{nameof(IterateToIndex)}");
+#endif
             return;
         }
 
@@ -615,6 +617,7 @@ public class ImageIterator : IAsyncDisposable
         {
             CurrentIndex = index;
 
+            // Get cached preload value first, if available
             // ReSharper disable once MethodHasAsyncOverload
             var preloadValue = GetPreLoadValue(index);
             if (preloadValue is not null)
@@ -625,7 +628,7 @@ public class ImageIterator : IAsyncDisposable
                     LoadingPreview();
 
                     using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
-                    linkedCts.CancelAfter(TimeSpan.FromMinutes(2));
+                    linkedCts.CancelAfter(TimeSpan.FromMinutes(1));
 
                     try
                     {
@@ -651,10 +654,13 @@ public class ImageIterator : IAsyncDisposable
             }
             else
             {
-                LoadingPreview();
-                preloadValue = await GetCurrentPreLoadValueAsync().ConfigureAwait(false);
+                var imageModel = await ProgressiveImageLoader.LoadProgressivelyAsync(
+                    new FileInfo(ImagePaths[index]), 
+                    _vm, 
+                    cts.Token);
+                preloadValue = new PreLoadValue(imageModel);
             }
-
+            
             if (CurrentIndex != index)
             {
                 // Skip loading if user went to next value
@@ -693,10 +699,10 @@ public class ImageIterator : IAsyncDisposable
             {
                 if (Settings.UIProperties.IsTaskbarProgressEnabled)
                 {
-                    await Dispatcher.UIThread.InvokeAsync(() =>
+                    Dispatcher.UIThread.Invoke(() =>
                     {
-                        _vm.PlatformService.SetTaskbarProgress((ulong)index, (ulong)ImagePaths.Count);
-                    });
+                        _vm.PlatformService.SetTaskbarProgress((ulong)CurrentIndex, (ulong)ImagePaths.Count);
+                    }, DispatcherPriority.Render);
                 }
 
                 await PreLoader.PreLoadAsync(index, IsReversed, ImagePaths)
@@ -741,7 +747,6 @@ public class ImageIterator : IAsyncDisposable
         void LoadingPreview()
         {
             TitleManager.SetLoadingTitle(_vm);
-            _vm.IsLoading = true;
 
             _vm.SelectedGalleryItemIndex = index;
             if (Settings.Gallery.IsBottomGalleryShown)
@@ -759,6 +764,7 @@ public class ImageIterator : IAsyncDisposable
             if (!Settings.ImageScaling.ShowImageSideBySide)
             {
                 _vm.PicViewer.ImageSource = thumb;
+                _vm.IsLoading = thumb is null;
             }
             else
             {
@@ -769,6 +775,7 @@ public class ImageIterator : IAsyncDisposable
                 }
                 _vm.PicViewer.ImageSource = thumb;
                 _vm.PicViewer.SecondaryImageSource = secondaryThumb;
+                _vm.IsLoading = thumb is null || secondaryThumb is null;
             }
         }
     }

+ 5 - 5
src/PicView.Avalonia/Navigation/UpdateImage.cs

@@ -117,20 +117,20 @@ public static class UpdateImage
         }
         else
         {
-            if (TiffManager.IsTiff(preLoadValue.ImageModel.FileInfo.FullName))
+            if (TiffManager.IsTiff(preLoadValue.ImageModel?.FileInfo?.FullName))
             {
-                if (TiffManager.IsTiff(preLoadValue.ImageModel.FileInfo.FullName))
+                if (TiffManager.IsTiff(preLoadValue.ImageModel?.FileInfo?.FullName))
                 {
-                    TitleManager.TrySetTiffTitle(preLoadValue.ImageModel, vm);
+                    TitleManager.TrySetTiffTitle(preLoadValue?.ImageModel, vm);
                 }
                 else
                 {
-                    TitleManager.SetTitle(vm, preLoadValue.ImageModel);
+                    TitleManager.SetTitle(vm, preLoadValue?.ImageModel);
                 }
             }
             else
             {
-                TitleManager.SetTitle(vm, preLoadValue.ImageModel);
+                TitleManager.SetTitle(vm, preLoadValue?.ImageModel);
             }
         }
         

+ 209 - 0
src/PicView.Avalonia/Preloading/ProgressiveImageLoader.cs

@@ -0,0 +1,209 @@
+using System.Diagnostics;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using ImageMagick;
+using PicView.Avalonia.ImageHandling;
+using PicView.Avalonia.Navigation;
+using PicView.Avalonia.UI;
+using PicView.Avalonia.ViewModels;
+using PicView.Avalonia.WindowBehavior;
+using PicView.Core.ImageDecoding;
+using PicView.Core.Localization;
+
+namespace PicView.Avalonia.Preloading;
+
+/// <summary>
+///     Handles progressive loading of images at multiple resolution stages.
+/// </summary>
+public static class ProgressiveImageLoader
+{
+    // Resolutions to generate, from lowest to highest (percentages of original)
+    private static readonly int[] ResolutionStages = [10, 25, 50, 100];
+
+    /// <summary>
+    ///     Loads an image progressively, updating the UI at each resolution stage
+    /// </summary>
+    public static async Task<ImageModel?> LoadProgressivelyAsync(FileInfo fileInfo, MainViewModel viewModel,
+        CancellationToken cancellationToken)
+    {
+        // Check for early cancellation
+        if (cancellationToken.IsCancellationRequested)
+        {
+            return null;
+        }
+
+        // Get image dimensions first without loading the entire image
+        using var magickImage = new MagickImage();
+        try
+        {
+            magickImage.Ping(fileInfo);
+        }
+        catch (Exception e)
+        {
+#if DEBUG
+            Trace.WriteLine($"\n{nameof(LoadProgressivelyAsync)} ping exception: \n{e.Message}\n{e.StackTrace}");
+#endif
+            // Handle broken pics
+            return new ImageModel
+            {
+                EXIFOrientation = EXIFHelper.EXIFOrientation.None,
+                PixelWidth = 0,
+                PixelHeight = 0,
+                FileInfo = fileInfo,
+                Image = null,
+                ImageType = ImageType.Invalid
+            };
+        }
+
+        var isLargeImage = magickImage.Width * magickImage.Height > 3500000; // ~3.5 megapixels threshold
+
+        // If it's a small image, just load it directly
+        if (!isLargeImage)
+        {
+            return await GetImageModel.GetImageModelAsync(fileInfo);
+        }
+
+        // Final model to return
+        var finalModel = new ImageModel
+        {
+            FileInfo = fileInfo,
+            PixelWidth = (int)magickImage.Width,
+            PixelHeight = (int)magickImage.Height
+        };
+
+        await UpdatePreview(magickImage.Width, magickImage.Height, finalModel, fileInfo, viewModel, cancellationToken)
+            .ConfigureAwait(false);
+
+        if (cancellationToken.IsCancellationRequested || finalModel.Image == null)
+        {
+            return finalModel;
+        }
+
+        // Load full metadata if we've succeeded
+        finalModel.EXIFOrientation = EXIFHelper.GetImageOrientation(fileInfo);
+        if (fileInfo.Extension.Equals(".gif", StringComparison.OrdinalIgnoreCase))
+        {
+            finalModel.ImageType = ImageAnalyzer.IsAnimated(fileInfo) ? ImageType.AnimatedGif : ImageType.Bitmap;
+        }
+        else if (fileInfo.Extension.Equals(".webp", StringComparison.OrdinalIgnoreCase))
+        {
+            finalModel.ImageType = ImageAnalyzer.IsAnimated(fileInfo) ? ImageType.AnimatedWebp : ImageType.Bitmap;
+        }
+        else
+        {
+            finalModel.ImageType = ImageType.Bitmap;
+        }
+
+        return finalModel;
+    }
+
+    public static async Task UpdatePreview(uint width, uint height, ImageModel finalModel, FileInfo fileInfo,
+        MainViewModel viewModel, CancellationToken cancellationToken)
+    {
+        var isSizeSet = false;
+
+        // For large files, start with EXIF thumbnail if available
+        var exifThumb = GetThumbnails.GetExifThumb(fileInfo.FullName);
+        if (exifThumb != null)
+        {
+            await UpdateUiWithImage(exifThumb, viewModel, finalModel, 0, isSizeSet);
+            isSizeSet = true;
+        }
+
+        // Load progressively increasing resolutions
+        for (var i = 0; i < ResolutionStages.Length && !cancellationToken.IsCancellationRequested; i++)
+        {
+            var percentage = ResolutionStages[i];
+
+            // Skip unnecessary resolution stages for performance
+            if (i > 0 && percentage < 100)
+            {
+                continue;
+            }
+
+            try
+            {
+                using var magick = await ImageDecoder.LoadImageAtResolutionAsync(
+                    fileInfo,
+                    percentage,
+                    width,
+                    height,
+                    cancellationToken).ConfigureAwait(false);
+                var bitmap = magick.ToWriteableBitmap();
+
+                if (bitmap != null && !cancellationToken.IsCancellationRequested)
+                {
+                    await UpdateUiWithImage(bitmap, viewModel, finalModel, percentage, isSizeSet);
+                }
+
+                isSizeSet = true;
+
+                // For the final resolution, update the model's image
+                if (percentage == 100 && bitmap != null)
+                {
+                    finalModel.Image = bitmap;
+                }
+            }
+            catch (OperationCanceledException)
+            {
+                // Handle cancellation silently
+                break;
+            }
+            catch (Exception ex)
+            {
+                // If we fail at a resolution stage but have a previous one, continue
+                if (i > 0)
+                {
+                    continue;
+                }
+
+                // If we fail at the initial stage, propagate the error
+                throw new Exception($"Failed to load image at {percentage}% resolution", ex);
+            }
+        }
+    }
+
+    private static async Task UpdateUiWithImage(
+        Bitmap image,
+        MainViewModel viewModel,
+        ImageModel model,
+        int percentage,
+        bool isSizeSet)
+    {
+        viewModel.PicViewer.ImageSource = image;
+
+        if (percentage >= 100)
+        {
+            return;
+        }
+
+        if (NavigationManager.CanNavigate(viewModel))
+        {
+            // Update title, pretend we're not loading to make it feel faster
+            TitleManager.SetTitle(viewModel, model);
+        }
+        else
+        {
+            // Update loading status message to show progress
+            viewModel.PicViewer.Title =
+                viewModel.PicViewer.WindowTitle =
+                    viewModel.PicViewer.TitleTooltip =
+                        $"{TranslationManager.Translation.Loading} {Path.GetFileName(model.FileInfo.Name)} ({percentage}%)";
+        }
+
+        // Don't show loading indicator, since we're already showing the image
+        viewModel.IsLoading = false;
+
+        if (!isSizeSet)
+        {
+            await Dispatcher.UIThread.InvokeAsync(() =>
+            {
+                WindowResizing.SetSize(model.PixelWidth, model.PixelHeight, 0, 0, model.Rotation, viewModel);
+                if (Settings.WindowProperties.AutoFit)
+                {
+                    WindowFunctions.CenterWindowOnScreen();
+                }
+            }, DispatcherPriority.Send);
+        }
+    }
+}

+ 96 - 79
src/PicView.Avalonia/StartUp/QuickLoad.cs

@@ -29,127 +29,137 @@ public static class QuickLoad
             await NavigationManager.LoadPicFromArchiveAsync(file, vm).ConfigureAwait(false);
             return;
         }
-        vm.PicViewer.FileInfo = fileInfo;
-        
-        var imageModel = await GetImageModel.GetImageModelAsync(fileInfo).ConfigureAwait(false);
         
+        vm.PicViewer.FileInfo = fileInfo;
+
+        if (Settings.ImageScaling.ShowImageSideBySide)
+        {
+            await SideBySideLoadingAsync(vm, fileInfo).ConfigureAwait(false);
+        }
+        else
+        {
+            await SingeImageLoadingAsync(vm, fileInfo).ConfigureAwait(false);
+        }
+
+        vm.IsLoading = false;
+    }
+
+    private static async Task SingeImageLoadingAsync(MainViewModel vm, FileInfo fileInfo)
+    {
+        var cancellationTokenSource = new CancellationTokenSource();
+        ImageModel? imageModel = null;
+        await Task.WhenAll(
+            Task.Run(() => { NavigationManager.InitializeImageIterator(vm); }, cancellationTokenSource.Token),
+            Task.Run(async () => imageModel = await ProgressiveImageLoader.LoadProgressivelyAsync(
+                fileInfo,vm, cancellationTokenSource.Token), cancellationTokenSource.Token));
+        await RenderingFixes(vm, imageModel, null);
+        SetPicViewerValues(vm, imageModel, fileInfo);
+        if (TiffManager.IsTiff(imageModel.FileInfo.FullName))
+        {
+            TitleManager.TrySetTiffTitle(imageModel, vm);
+        }
+        else
+        {
+            TitleManager.SetTitle(vm, imageModel);
+        }
+        await StartPreloaderAndGalleryAsync(vm, imageModel, fileInfo);
+        cancellationTokenSource.Dispose();
+    }
+
+    private static async Task SideBySideLoadingAsync(MainViewModel vm, FileInfo fileInfo)
+    {
+        NavigationManager.InitializeImageIterator(vm);
+        var imageModel = await GetImageModel.GetImageModelAsync(fileInfo);
+        var secondaryPreloadValue = await NavigationManager.GetNextPreLoadValueAsync();
+        vm.PicViewer.SecondaryImageSource = secondaryPreloadValue?.ImageModel?.Image;
+        SetPicViewerValues(vm, imageModel, fileInfo);
+        await RenderingFixes(vm, imageModel, secondaryPreloadValue.ImageModel);
+        TitleManager.SetSideBySideTitle(vm, imageModel, secondaryPreloadValue?.ImageModel);
+            
+        // Sometimes the images are not rendered in side by side, this fixes it
+        // TODO: Improve and fix side by side and remove this hack 
+        Dispatcher.UIThread.Post(() =>
+        {
+            vm.ImageViewer?.MainImage?.InvalidateVisual();
+        });
+        await StartPreloaderAndGalleryAsync(vm, imageModel, fileInfo);
+    }
+
+    private static void SetPicViewerValues(MainViewModel vm, ImageModel imageModel, FileInfo fileInfo)
+    {
         if (imageModel.ImageType is ImageType.AnimatedGif or ImageType.AnimatedWebp)
         {
-            vm.ImageViewer.MainImage.InitialAnimatedSource = file;
+            vm.ImageViewer.MainImage.InitialAnimatedSource = fileInfo.FullName;
         }
+        
         vm.PicViewer.ImageSource = imageModel.Image;
         vm.PicViewer.ImageType = imageModel.ImageType;
         vm.ZoomValue = 1;
         vm.PicViewer.PixelWidth = imageModel.PixelWidth;
         vm.PicViewer.PixelHeight = imageModel.PixelHeight;
-        PreLoadValue? secondaryPreloadValue = null;
-        if (Settings.ImageScaling.ShowImageSideBySide)
-        {
-            NavigationManager.InitializeImageIterator(vm);
-            secondaryPreloadValue = await NavigationManager.GetNextPreLoadValueAsync();
-            vm.PicViewer.SecondaryImageSource = secondaryPreloadValue?.ImageModel?.Image;
-        }
         
+        vm.PicViewer.ExifOrientation = imageModel.EXIFOrientation;
+        vm.GetIndex = NavigationManager.GetNonZeroIndex;
+    }
+
+    private static async Task RenderingFixes(MainViewModel vm, ImageModel imageModel, ImageModel? secondaryModel)
+    {
         // When width and height are the same, it renders image incorrectly at startup,
         // so need to handle it specially
         var is1To1 = imageModel.PixelWidth == imageModel.PixelHeight;
-        
+
         await Dispatcher.UIThread.InvokeAsync(() =>
         {
             vm.ImageViewer.SetTransform(imageModel.EXIFOrientation, false);
             if (Settings.WindowProperties.AutoFit && !Settings.Zoom.ScrollEnabled)
             {
-                SetSize();
+                SetSize(vm, imageModel, secondaryModel);
                 WindowFunctions.CenterWindowOnScreen();
             }
             else if (is1To1)
             {
-                var size = WindowResizing.GetSize(imageModel.PixelWidth, imageModel.PixelHeight, secondaryPreloadValue?.ImageModel?.PixelWidth ?? 0, secondaryPreloadValue?.ImageModel?.PixelHeight ?? 0, vm.RotationAngle, vm);
+                var size = WindowResizing.GetSize(imageModel.PixelWidth, imageModel.PixelHeight,
+                    secondaryModel?.PixelWidth ?? 0, secondaryModel?.PixelHeight ?? 0, vm.RotationAngle, vm);
                 if (!size.HasValue)
                 {
 #if DEBUG
-         Console.WriteLine($"{nameof(QuickLoadAsync)} {nameof(size)} is null");           
+                    Console.WriteLine($"{nameof(QuickLoadAsync)} {nameof(size)} is null");
 #endif
                     ErrorHandling.ShowStartUpMenu(vm);
                     return;
                 }
+
                 WindowResizing.SetSize(size.Value, vm);
                 vm.ImageViewer.MainBorder.Height = size.Value.Width;
                 vm.ImageViewer.MainBorder.Width = size.Value.Height;
             }
-            else if (imageModel.PixelWidth <= UIHelper.GetMainView.Bounds.Width && imageModel.PixelHeight <= UIHelper.GetMainView.Bounds.Height)
+            else if (imageModel.PixelWidth <= UIHelper.GetMainView.Bounds.Width &&
+                     imageModel.PixelHeight <= UIHelper.GetMainView.Bounds.Height)
             {
-                SetSize();
+                SetSize(vm, imageModel, secondaryModel);
             }
-        }, DispatcherPriority.Send);
-
-        vm.IsLoading = false;
-        
-        NavigationManager.InitializeImageIterator(vm);
-        
-        if (Settings.ImageScaling.ShowImageSideBySide)
-        {
-            TitleManager.SetSideBySideTitle(vm, imageModel, secondaryPreloadValue?.ImageModel);
             
-            // Sometimes the images are not rendered in side by side, this fixes it
-            // TODO: Improve and fix side by side and remove this hack 
-            Dispatcher.UIThread.Post(() =>
-            {
-                vm.ImageViewer?.MainImage?.InvalidateVisual();
-            });
-        }
-        else
-        {
-            if (TiffManager.IsTiff(imageModel.FileInfo.FullName))
-            {
-                TitleManager.TrySetTiffTitle(imageModel, vm);
-            }
-            else
+            if (Settings.Zoom.ScrollEnabled)
             {
-                TitleManager.SetTitle(vm, imageModel);
+                // Bad fix for scrolling
+                // TODO: Implement proper startup scrolling fix
+                Settings.Zoom.ScrollEnabled = false;
             }
-        }
+        }, DispatcherPriority.Send);
         
-        // Fixes weird bug where the image is not rendered correctly
-        // TODO: check if this will still be needed in future Avalonia versions
-        if (!Settings.WindowProperties.AutoFit && !Settings.Zoom.ScrollEnabled)
-        {
-            if (!is1To1)
-            {
-                await Dispatcher.UIThread.InvokeAsync(() =>
-                {
-                    if (imageModel.PixelWidth > UIHelper.GetMainView.Bounds.Width || imageModel.PixelHeight > UIHelper.GetMainView.Bounds.Height
-                        || imageModel.PixelWidth == imageModel.PixelHeight)
-                    {
-                        WindowResizing.SetSize(1, 1, 0, 0, 0, vm);
-                    }
-                });
-                await Dispatcher.UIThread.InvokeAsync(() =>
-                {
-                    if (imageModel.PixelWidth > UIHelper.GetMainView.Bounds.Width || imageModel.PixelHeight > UIHelper.GetMainView.Bounds.Height)
-                    {
-                        vm.ImageViewer.MainBorder.Height = double.NaN;
-                        vm.ImageViewer.MainBorder.Width = double.NaN;
-
-                        SetSize();
-                    }
-                }, DispatcherPriority.Send);
-            }
-        }
-
         if (Settings.Zoom.ScrollEnabled)
         {
             // Bad fix for scrolling
             // TODO: Implement proper startup scrolling fix
             Settings.Zoom.ScrollEnabled = false;
-            await Dispatcher.UIThread.InvokeAsync(SetSize, DispatcherPriority.Background);
+            await Dispatcher.UIThread.InvokeAsync(() => SetSize(vm, imageModel, secondaryModel), DispatcherPriority.Render);
             Settings.Zoom.ScrollEnabled = true;
-            await Dispatcher.UIThread.InvokeAsync(SetSize, DispatcherPriority.Send);
+            await Dispatcher.UIThread.InvokeAsync(() => SetSize(vm, imageModel, secondaryModel), DispatcherPriority.Send);
         }
-
-        vm.PicViewer.ExifOrientation = imageModel.EXIFOrientation;
-        vm.GetIndex = NavigationManager.GetNonZeroIndex;
-        
+    }
+    
+    private static async Task StartPreloaderAndGalleryAsync(MainViewModel vm, ImageModel imageModel, FileInfo fileInfo)
+    {
         // Add recent files, except when browsing archive
         if (string.IsNullOrWhiteSpace(TempFileHelper.TempFilePath))
         {
@@ -193,12 +203,19 @@ public static class QuickLoad
         }
 
         await Task.WhenAll(tasks).ConfigureAwait(false);
-        
-        return;
+    }
 
-        void SetSize()
+    private static void SetSize(MainViewModel vm, ImageModel imageModel, ImageModel? secondaryModel)
+    {
+        var size = WindowResizing.GetSize(imageModel.PixelWidth, imageModel.PixelHeight, secondaryModel?.PixelWidth ?? 0, secondaryModel?.PixelHeight ?? 0, vm.RotationAngle, vm);
+        if (!size.HasValue)
         {
-            WindowResizing.SetSize(imageModel.PixelWidth, imageModel.PixelHeight, secondaryPreloadValue?.ImageModel?.PixelWidth ?? 0, secondaryPreloadValue?.ImageModel?.PixelHeight ?? 0, imageModel.Rotation, vm);
+#if DEBUG
+            Console.WriteLine($"{nameof(QuickLoadAsync)} {nameof(size)} is null");           
+#endif
+            ErrorHandling.ShowStartUpMenu(vm);
+            return;
         }
+        WindowResizing.SetSize(size.Value, vm);
     }
 }

+ 3 - 3
src/PicView.Avalonia/Views/ImageViewer.axaml.cs

@@ -313,7 +313,7 @@ public partial class ImageViewer : UserControl
         }
     }
 
-    public void SetTransform(EXIFHelper.EXIFOrientation? orientation, bool resetZoom = true)
+    public void SetTransform(EXIFHelper.EXIFOrientation? orientation, bool reset = true)
     {
         if (Dispatcher.UIThread.CheckAccess())
         {
@@ -338,9 +338,9 @@ public partial class ImageViewer : UserControl
                 default:
                 case EXIFHelper.EXIFOrientation.None:
                 case EXIFHelper.EXIFOrientation.Horizontal:
-                    if (resetZoom)
+                    if (reset)
                     {
-                        ResetZoom();
+                        SetTransform(1,0);
                     }
                     return;
                 case EXIFHelper.EXIFOrientation.MirrorHorizontal:

+ 37 - 11
src/PicView.Core/ImageDecoding/ImageDecoder.cs

@@ -4,18 +4,10 @@ using ImageMagick;
 namespace PicView.Core.ImageDecoding;
 
 /// <summary>
-/// Provides methods for decoding various image formats.
+///     Provides methods for decoding various image formats.
 /// </summary>
 public static class ImageDecoder
 {
-    private static readonly string[] Defines =
-    [
-        "34022", // ColorTable
-        "34025", // ImageColorValue
-        "34026", // BackgroundColorValue
-        "32928"
-    ];
-
     public static MagickImage? Base64ToMagickImage(string base64)
     {
         try
@@ -33,8 +25,8 @@ public static class ImageDecoder
                 BackgroundColor = MagickColors.Transparent
             };
 
-           magickImage.Read(new MemoryStream(base64Data), readSettings);
-           return magickImage;
+            magickImage.Read(new MemoryStream(base64Data), readSettings);
+            return magickImage;
         }
         catch (Exception e)
         {
@@ -50,4 +42,38 @@ public static class ImageDecoder
         var base64String = await File.ReadAllTextAsync(fileInfo.FullName).ConfigureAwait(false);
         return Base64ToMagickImage(base64String);
     }
+
+    public static async Task<MagickImage> LoadImageAtResolutionAsync(
+        FileInfo fileInfo,
+        int percentage,
+        double originalWidth,
+        double originalHeight,
+        CancellationToken cancellationToken)
+    {
+        return await Task.Run(() =>
+        {
+            var magickImage = new MagickImage();
+
+            // Fast metadata read for optimization targets
+            if (percentage < 100)
+            {
+                var settings = new MagickReadSettings
+                {
+                    Width = (uint?)(originalWidth * percentage / 100),
+                    Height = (uint?)(originalHeight * percentage / 100)
+                };
+                magickImage.Read(fileInfo, settings);
+            }
+            else
+            {
+                // For 100%, read the full image
+                magickImage.Read(fileInfo);
+            }
+
+            // Check cancellation before expensive operations
+            cancellationToken.ThrowIfCancellationRequested();
+
+            return magickImage;
+        }, cancellationToken);
+    }
 }