Browse Source

Async video preview on export wip

flabbet 1 year ago
parent
commit
3b1a04e263

+ 1 - 0
src/ChunkyImageLib/Surface.cs

@@ -210,6 +210,7 @@ public class Surface : IDisposable
         disposed = true;
         drawingPaint.Dispose();
         nearestNeighborReplacingPaint.Dispose();
+        DrawingSurface.Dispose();
         Marshal.FreeHGlobal(PixelBuffer);
         GC.SuppressFinalize(this);
     }

+ 58 - 18
src/PixiEditor.AnimationRenderer.FFmpeg/FFMpegRenderer.cs

@@ -11,7 +11,8 @@ public class FFMpegRenderer : IAnimationRenderer
 {
     public int FrameRate { get; set; } = 60;
     public string OutputFormat { get; set; } = "mp4";
-    public VecI Size { get; set; } 
+    public VecI Size { get; set; }
+
     public async Task<bool> RenderAsync(string framesPath, string outputPath)
     {
         string[] frames = Directory.GetFiles(framesPath, "*.png");
@@ -19,38 +20,77 @@ public class FFMpegRenderer : IAnimationRenderer
         {
             return false;
         }
-        
+
         string[] finalFrames = new string[frames.Length];
 
         for (int i = 0; i < frames.Length; i++)
         {
-            if(int.TryParse(Path.GetFileNameWithoutExtension(frames[i]), out int frameNumber))
+            if (int.TryParse(Path.GetFileNameWithoutExtension(frames[i]), out int frameNumber))
             {
                 finalFrames[frameNumber] = frames[i];
             }
         }
-        
-        GlobalFFOptions.Configure(new FFOptions() { BinaryFolder = @"C:\ProgramData\chocolatey\lib\ffmpeg\tools\ffmpeg\bin" });
-        
-        float duration = finalFrames.Length / (float)FrameRate;
 
-        if (RequiresPaletteGeneration())
+        GlobalFFOptions.Configure(new FFOptions()
         {
-            GeneratePalette(finalFrames, framesPath);
-        }
-        
-        return await FFMpegArguments
-            .FromConcatInput(finalFrames, options =>
+            BinaryFolder = @"C:\ProgramData\chocolatey\lib\ffmpeg\tools\ffmpeg\bin"
+        });
+
+        try
+        {
+            if (RequiresPaletteGeneration())
             {
-                options.WithFramerate(FrameRate);
-            })
+                GeneratePalette(finalFrames, framesPath);
+            }
+
+            var args = FFMpegArguments
+                .FromConcatInput(finalFrames, options =>
+                {
+                    options.WithFramerate(FrameRate);
+                });
+
+            var outputArgs = GetProcessorForFormat(args, framesPath, outputPath);
+            return await outputArgs.ProcessAsynchronously();
+        }
+        catch (Exception e)
+        {
+            Console.WriteLine(e);
+            return false;
+        }
+    }
+
+    private FFMpegArgumentProcessor GetProcessorForFormat(FFMpegArguments args, string framesPath, string outputPath)
+    {
+        return OutputFormat switch
+        {
+            "gif" => GetGifArguments(args, framesPath, outputPath),
+            "mp4" => GetMp4Arguments(args, outputPath),
+            _ => throw new NotSupportedException($"Output format {OutputFormat} is not supported")
+        };
+    }
+
+    private FFMpegArgumentProcessor GetGifArguments(FFMpegArguments args, string framesPath, string outputPath)
+    {
+        return args
             .AddFileInput(Path.Combine(framesPath, "palette.png"))
             .OutputToFile(outputPath, true, options =>
             {
-                options.WithCustomArgument($"-filter_complex \"[0:v]fps={FrameRate},scale={Size.X}:{Size.Y}:flags=lanczos[x];[x][1:v]paletteuse\"") // Apply the palette
+                options.WithCustomArgument(
+                        $"-filter_complex \"[0:v]fps={FrameRate},scale={Size.X}:{Size.Y}:flags=lanczos[x];[x][1:v]paletteuse\"") // Apply the palette
                     .WithCustomArgument($"-vsync 0"); // Ensure each input frame gets displayed exactly once
-            })
-            .ProcessAsynchronously();
+            });
+    }
+    
+    private FFMpegArgumentProcessor GetMp4Arguments(FFMpegArguments args, string outputPath)
+    {
+        return args
+            .OutputToFile(outputPath, true, options =>
+            {
+                options.WithFramerate(FrameRate)
+                    .WithVideoCodec(VideoCodec.LibX264)
+                    .WithConstantRateFactor(21)
+                    .ForcePixelFormat("yuv420p");
+            });
     }
 
     private bool RequiresPaletteGeneration()

+ 1 - 0
src/PixiEditor.AvaloniaUI/Data/Localization/Languages/en.json

@@ -604,4 +604,5 @@
   "BMP_FILE": "BMP Images",
   "IMAGE_FILES": "Image Files",
   "VIDEO_FILES": "Video Files",
+  "MP4_FILE": "MP4 Videos"
 }

+ 1 - 0
src/PixiEditor.AvaloniaUI/Helpers/ServiceCollectionHelpers.cs

@@ -109,6 +109,7 @@ internal static class ServiceCollectionHelpers
             .AddSingleton<IoFileType, JpegFileType>()
             .AddSingleton<IoFileType, BmpFileType>()
             .AddSingleton<IoFileType, GifFileType>()
+            .AddSingleton<IoFileType, Mp4FileType>()
             // Palette Parsers
             .AddSingleton<IPalettesProvider, PaletteProvider>()
             .AddSingleton<PaletteFileParser, JascFileParser>()

+ 5 - 5
src/PixiEditor.AvaloniaUI/Models/Files/ImageFileType.cs

@@ -15,7 +15,7 @@ internal abstract class ImageFileType : IoFileType
     
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Image;
 
-    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig exportConfig)
+    public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig exportConfig)
     {
         var maybeBitmap = document.TryRenderWholeImage();
         if (maybeBitmap.IsT0)
@@ -30,13 +30,13 @@ internal abstract class ImageFileType : IoFileType
         }
 
         UniversalFileEncoder encoder = new(mappedFormat);
-        return TrySaveAs(encoder, pathWithExtension, bitmap, exportConfig);
+        return await TrySaveAs(encoder, pathWithExtension, bitmap, exportConfig);
     }
     
     /// <summary>
     /// Saves image to PNG file. Messes with the passed bitmap.
     /// </summary>
-    private static SaveResult TrySaveAs(IFileEncoder encoder, string savePath, Surface bitmap, ExportConfig config)
+    private static async Task<SaveResult> TrySaveAs(IFileEncoder encoder, string savePath, Surface bitmap, ExportConfig config)
     {
         try
         {
@@ -47,8 +47,8 @@ internal abstract class ImageFileType : IoFileType
             if (!encoder.SupportsTransparency)
                 bitmap.DrawingSurface.Canvas.DrawColor(Colors.White, DrawingApi.Core.Surface.BlendMode.Multiply);
 
-            using var stream = new FileStream(savePath, FileMode.Create);
-            encoder.SaveAsync(stream, bitmap);
+            await using var stream = new FileStream(savePath, FileMode.Create);
+            await encoder.SaveAsync(stream, bitmap);
         }
         catch (SecurityException)
         {

+ 1 - 1
src/PixiEditor.AvaloniaUI/Models/Files/IoFileType.cs

@@ -45,5 +45,5 @@ internal abstract class IoFileType
         return "*" + extension;
     }
 
-    public abstract SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config);
+    public abstract Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config);
 }

+ 9 - 0
src/PixiEditor.AvaloniaUI/Models/Files/Mp4FileType.cs

@@ -0,0 +1,9 @@
+using PixiEditor.Extensions.Common.Localization;
+
+namespace PixiEditor.AvaloniaUI.Models.Files;
+
+internal class Mp4FileType : VideoFileType
+{
+    public override string[] Extensions { get; } = { ".mp4" };
+    public override string DisplayName { get; } = new LocalizedString("MP4_FILE");
+}

+ 2 - 2
src/PixiEditor.AvaloniaUI/Models/Files/PixiFileType.cs

@@ -16,11 +16,11 @@ internal class PixiFileType : IoFileType
 
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Pixi;
 
-    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config)
+    public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config)
     {
         try
         {
-            Parser.PixiParser.Serialize(document.ToSerializable(), pathWithExtension);
+            await Parser.PixiParser.SerializeAsync(document.ToSerializable(), pathWithExtension);
         }
         catch (UnauthorizedAccessException e)
         {

+ 3 - 3
src/PixiEditor.AvaloniaUI/Models/Files/VideoFileType.cs

@@ -6,7 +6,7 @@ namespace PixiEditor.AvaloniaUI.Models.Files;
 internal abstract class VideoFileType : IoFileType
 {
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Video;
-    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config)
+    public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config)
     {
         if (config.AnimationRenderer is null)
             return SaveResult.UnknownError;
@@ -21,7 +21,7 @@ internal abstract class VideoFileType : IoFileType
             return surface;
         });
         
-        config.AnimationRenderer.RenderAsync(Paths.TempRenderingPath, pathWithExtension);
-        return SaveResult.Success;
+        var result = await config.AnimationRenderer.RenderAsync(Paths.TempRenderingPath, pathWithExtension);
+        return result ? SaveResult.Success : SaveResult.UnknownError;
     }
 }

+ 10 - 10
src/PixiEditor.AvaloniaUI/Models/IO/Exporter.cs

@@ -69,13 +69,13 @@ internal class Exporter
 
             var fileType = SupportedFilesHelper.GetSaveFileType(FileTypeDialogDataSet.SetKind.Any, file);
 
-            var saveResult = TrySaveUsingDataFromDialog(document, file.Path.LocalPath, fileType, out string fixedPath, exportConfig);
-            if (saveResult == SaveResult.Success)
+            (SaveResult Result, string finalPath) saveResult = await TrySaveUsingDataFromDialog(document, file.Path.LocalPath, fileType, exportConfig);
+            if (saveResult.Result == SaveResult.Success)
             {
-                result.Path = fixedPath;
+                result.Path = saveResult.finalPath;
             }
 
-            result.Result = (DialogSaveResult)saveResult;
+            result.Result = (DialogSaveResult)saveResult.Result;
         }
 
         return result;
@@ -84,20 +84,20 @@ internal class Exporter
     /// <summary>
     /// Takes data as returned by SaveFileDialog and attempts to use it to save the document
     /// </summary>
-    public static SaveResult TrySaveUsingDataFromDialog(DocumentViewModel document, string pathFromDialog, IoFileType fileTypeFromDialog, out string finalPath, ExportConfig exportConfig)
+    public static async Task<(SaveResult result, string finalPath)> TrySaveUsingDataFromDialog(DocumentViewModel document, string pathFromDialog, IoFileType fileTypeFromDialog, ExportConfig exportConfig)
     {
-        finalPath = SupportedFilesHelper.FixFileExtension(pathFromDialog, fileTypeFromDialog);
-        var saveResult = TrySave(document, finalPath, exportConfig);
+        string finalPath = SupportedFilesHelper.FixFileExtension(pathFromDialog, fileTypeFromDialog);
+        var saveResult = await TrySaveAsync(document, finalPath, exportConfig);
         if (saveResult != SaveResult.Success)
             finalPath = "";
 
-        return saveResult;
+        return (saveResult, finalPath);
     }
 
     /// <summary>
     /// Attempts to save the document into the given location, filetype is inferred from path
     /// </summary>
-    public static SaveResult TrySave(DocumentViewModel document, string pathWithExtension, ExportConfig exportConfig)
+    public static async Task<SaveResult> TrySaveAsync(DocumentViewModel document, string pathWithExtension, ExportConfig exportConfig)
     {
         string directory = Path.GetDirectoryName(pathWithExtension);
         if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
@@ -108,7 +108,7 @@ internal class Exporter
         if (typeFromPath is null)
             return SaveResult.UnknownError;
         
-        return typeFromPath.TrySave(pathWithExtension, document, exportConfig);
+        return await typeFromPath.TrySave(pathWithExtension, document, exportConfig);
     }
 
     public static void SaveAsGZippedBytes(string path, Surface surface)

+ 25 - 11
src/PixiEditor.AvaloniaUI/ViewModels/Document/DocumentViewModel.cs

@@ -355,7 +355,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         lastChangeOnSave = Guid.NewGuid();
         OnPropertyChanged(nameof(AllChangesSaved));
     }
-    
+
 
     /// <summary>
     /// Tries rendering the whole document
@@ -692,7 +692,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
     public Image[] RenderFrames(Func<Surface, Surface> processFrameAction = null)
     {
         if (AnimationDataViewModel.KeyFrames.Count == 0)
-            return[];
+            return [];
 
         var keyFrames = AnimationDataViewModel.KeyFrames;
         var firstFrame = keyFrames.Min(x => x.StartFrameBindable);
@@ -703,22 +703,29 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         Image[] images = new Image[lastFrame - firstFrame];
         for (int i = firstFrame; i < lastFrame; i++)
         {
-            Internals.Tracker.ProcessActionsSync(new List<IAction> { new ActiveFrame_Action(i), new EndActiveFrame_Action() });
+            Internals.Tracker.ProcessActionsSync(new List<IAction>
+            {
+                new ActiveFrame_Action(i), new EndActiveFrame_Action()
+            });
             var surface = TryRenderWholeImage();
             if (surface.IsT0)
             {
                 continue;
             }
-            
+
             if (processFrameAction is not null)
             {
                 surface = processFrameAction(surface.AsT1);
             }
-            
+
             images[i] = surface.AsT1.DrawingSurface.Snapshot();
+            surface.AsT1.Dispose();
         }
 
-        Internals.Tracker.ProcessActionsSync(new List<IAction> { new ActiveFrame_Action(activeFrame), new EndActiveFrame_Action() });
+        Internals.Tracker.ProcessActionsSync(new List<IAction>
+        {
+            new ActiveFrame_Action(activeFrame), new EndActiveFrame_Action()
+        });
         return images;
     }
 
@@ -741,26 +748,33 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         var lastFrame = keyFrames.Max(x => x.StartFrameBindable + x.DurationBindable);
 
         int activeFrame = AnimationDataViewModel.ActiveFrameBindable;
-        
+
         for (int i = firstFrame; i < lastFrame; i++)
         {
-            Internals.Tracker.ProcessActionsSync(new List<IAction> { new ActiveFrame_Action(i), new EndActiveFrame_Action() });
+            Internals.Tracker.ProcessActionsSync(new List<IAction>
+            {
+                new ActiveFrame_Action(i), new EndActiveFrame_Action()
+            });
             var surface = TryRenderWholeImage();
             if (surface.IsT0)
             {
                 return false;
             }
-            
+
             if (processFrameAction is not null)
             {
                 surface = processFrameAction(surface.AsT1);
             }
+
             using var stream = new FileStream(Path.Combine(tempRenderingPath, $"{i}.png"), FileMode.Create);
             surface.AsT1.DrawingSurface.Snapshot().Encode().SaveTo(stream);
             stream.Position = 0;
         }
-        
-        Internals.Tracker.ProcessActionsSync(new List<IAction> { new ActiveFrame_Action(activeFrame), new EndActiveFrame_Action() });
+
+        Internals.Tracker.ProcessActionsSync(new List<IAction>
+        {
+            new ActiveFrame_Action(activeFrame), new EndActiveFrame_Action()
+        });
         return true;
     }
 

+ 5 - 6
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/FileViewModel.cs

@@ -342,7 +342,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         }
         else
         {
-            var result = Exporter.TrySave(document, document.FullFilePath, ExportConfig.Empty);
+            var result = await Exporter.TrySaveAsync(document, document.FullFilePath, ExportConfig.Empty);
             if (result != SaveResult.Success)
             {
                 ShowSaveError((DialogSaveResult)result);
@@ -378,12 +378,11 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
             };
             if (await info.ShowDialog())
             {
-                SaveResult result = Exporter.TrySaveUsingDataFromDialog(doc, info.FilePath, info.ChosenFormat,
-                    out string finalPath, info.ExportConfig);
-                if (result == SaveResult.Success)
-                    IOperatingSystem.Current.OpenFolder(finalPath);
+                var result = await Exporter.TrySaveUsingDataFromDialog(doc, info.FilePath, info.ChosenFormat, info.ExportConfig);
+                if (result.result == SaveResult.Success)
+                    IOperatingSystem.Current.OpenFolder(result.finalPath);
                 else
-                    ShowSaveError((DialogSaveResult)result);
+                    ShowSaveError((DialogSaveResult)result.result);
             }
         }
         catch (RecoverableException e)

+ 116 - 26
src/PixiEditor.AvaloniaUI/Views/Dialogs/ExportFilePopup.axaml.cs

@@ -1,5 +1,6 @@
 using System.Threading.Tasks;
 using Avalonia;
+using Avalonia.Controls;
 using Avalonia.Platform.Storage;
 using Avalonia.Threading;
 using ChunkyImageLib;
@@ -8,9 +9,9 @@ using PixiEditor.AvaloniaUI.Helpers;
 using PixiEditor.AvaloniaUI.Models.Files;
 using PixiEditor.AvaloniaUI.Models.IO;
 using PixiEditor.AvaloniaUI.ViewModels.Document;
-using PixiEditor.DrawingApi.Core.Surface.ImageData;
 using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Numerics;
+using Image = PixiEditor.DrawingApi.Core.Surface.ImageData.Image;
 
 namespace PixiEditor.AvaloniaUI.Views.Dialogs;
 
@@ -109,6 +110,7 @@ internal partial class ExportFilePopup : PixiEditorPopup
     private Image[] videoPreviewFrames = [];
     private DispatcherTimer videoPreviewTimer = new DispatcherTimer();
     private int activeFrame = 0;
+    private CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
 
     static ExportFilePopup()
     {
@@ -132,10 +134,51 @@ internal partial class ExportFilePopup : PixiEditorPopup
         SetBestPercentageCommand = new RelayCommand(SetBestPercentage);
         ExportCommand = new AsyncRelayCommand(Export);
         this.document = document;
+        videoPreviewTimer = new DispatcherTimer(DispatcherPriority.Normal)
+        {
+            Interval = TimeSpan.FromMilliseconds(1000f / document.AnimationDataViewModel.FrameRate)
+        };
+        videoPreviewTimer.Tick += OnVideoPreviewTimerOnTick;
 
         RenderPreview();
     }
 
+    protected override void OnClosing(WindowClosingEventArgs e)
+    {
+        base.OnClosing(e);
+        videoPreviewTimer.Stop();
+        videoPreviewTimer.Tick -= OnVideoPreviewTimerOnTick;
+        videoPreviewTimer = null;
+        cancellationTokenSource.Dispose();
+
+        if (ExportPreview != null)
+        {
+            ExportPreview.Dispose();
+        }
+
+        if (videoPreviewFrames != null)
+        {
+            foreach (var frame in videoPreviewFrames)
+            {
+                frame.Dispose();
+            }
+        }
+    }
+
+    private void OnVideoPreviewTimerOnTick(object? o, EventArgs eventArgs)
+    {
+        if (videoPreviewFrames.Length > 0)
+        {
+            ExportPreview.DrawingSurface.Canvas.Clear();
+            ExportPreview.DrawingSurface.Canvas.DrawImage(videoPreviewFrames[activeFrame], 0, 0);
+            activeFrame = (activeFrame + 1) % videoPreviewFrames.Length;
+        }
+        else
+        {
+            videoPreviewTimer.Stop();
+        }
+    }
+
     private void RenderPreview()
     {
         if (document == null)
@@ -146,41 +189,88 @@ internal partial class ExportFilePopup : PixiEditorPopup
         videoPreviewTimer.Stop();
         if (IsVideoExport)
         {
-            videoPreviewFrames = document.RenderFrames(surface =>
+            StartRenderAnimationJob();
+            videoPreviewTimer.Interval = TimeSpan.FromMilliseconds(1000f / document.AnimationDataViewModel.FrameRate);
+        }
+        else
+        {
+            var rendered = document.TryRenderWholeImage();
+            if (rendered.IsT1)
             {
-                if (SaveWidth != surface.Size.X || SaveHeight != surface.Size.Y)
-                {
-                    return surface.ResizeNearestNeighbor(new VecI(SaveWidth, SaveHeight));
-                }
+                VecI previewSize = CalculatePreviewSize(rendered.AsT1.Size);
+                ExportPreview = rendered.AsT1.ResizeNearestNeighbor(previewSize);
+                rendered.AsT1.Dispose();
+            }
+        }
+    }
 
-                return surface;
-            });
-            videoPreviewTimer = new DispatcherTimer(DispatcherPriority.Normal)
-            {
-                Interval = TimeSpan.FromMilliseconds(1000f / document.AnimationDataViewModel.FrameRate)
-            };
-            videoPreviewTimer.Tick += (_, _) =>
+    private void StartRenderAnimationJob()
+    {
+        if (cancellationTokenSource.Token != null && cancellationTokenSource.Token.CanBeCanceled)
+        {
+            cancellationTokenSource.Cancel();
+        }
+
+        cancellationTokenSource = new CancellationTokenSource();
+
+        Task.Run(
+            () =>
             {
-                if (videoPreviewFrames.Length > 0)
+                videoPreviewFrames = document.RenderFrames(surface =>
                 {
-                    ExportPreview.DrawingSurface.Canvas.Clear();
-                    ExportPreview.DrawingSurface.Canvas.DrawImage(videoPreviewFrames[activeFrame], 0, 0);
-                    activeFrame = (activeFrame + 1) % videoPreviewFrames.Length;
-                }
-                else
+                    return Dispatcher.UIThread.Invoke(() =>
+                    {
+                        Surface original = surface;
+                        if (SaveWidth != surface.Size.X || SaveHeight != surface.Size.Y)
+                        {
+                            original = surface.ResizeNearestNeighbor(new VecI(SaveWidth, SaveHeight));
+                            surface.Dispose();
+                        }
+
+                        VecI previewSize = CalculatePreviewSize(original.Size);
+                        if (previewSize != original.Size)
+                        {
+                            var resized = original.ResizeNearestNeighbor(previewSize);
+                            original.Dispose();
+                            return resized;
+                        }
+
+                        return original;
+                    });
+                });
+            }, cancellationTokenSource.Token).ContinueWith(_ =>
+        {
+            Dispatcher.UIThread.Invoke(() =>
+            {
+                VecI previewSize = CalculatePreviewSize(new VecI(SaveWidth, SaveHeight));
+                if (previewSize != ExportPreview.Size)
                 {
-                    videoPreviewTimer.Stop();
+                    ExportPreview?.Dispose();
+                    ExportPreview = new Surface(previewSize);
                 }
-            };
-            
+            });
+
             videoPreviewTimer.Start();
-        }
+        });
+    }
 
-        var rendered = document.TryRenderWholeImage();
-        if (rendered.IsT1)
+    private VecI CalculatePreviewSize(VecI imageSize)
+    {
+        VecI maxPreviewSize = new VecI(150, 200);
+        if (imageSize.X > maxPreviewSize.X || imageSize.Y > maxPreviewSize.Y)
         {
-            ExportPreview = rendered.AsT1.ResizeNearestNeighbor(new VecI(SaveWidth, SaveHeight));
+            float scaleX = maxPreviewSize.X / (float)imageSize.X;
+            float scaleY = maxPreviewSize.Y / (float)imageSize.Y;
+
+            float scale = Math.Min(scaleX, scaleY);
+
+            int newWidth = (int)(imageSize.X * scale);
+            int newHeight = (int)(imageSize.Y * scale);
+
+            return new VecI(newWidth, newHeight);
         }
+
+        return imageSize;
     }
 
     private async Task Export()