2
0
Эх сурвалжийг харах

Added progress bar to rendering

flabbet 11 сар өмнө
parent
commit
ffb5e20c2f

+ 1 - 1
src/PixiEditor.AnimationRenderer.Core/IAnimationRenderer.cs

@@ -4,5 +4,5 @@ namespace PixiEditor.AnimationRenderer.Core;
 
 public interface IAnimationRenderer
 {
-    public Task<bool> RenderAsync(List<Image> imageStream, string outputPath);
+    public Task<bool> RenderAsync(List<Image> imageStream, string outputPath, CancellationToken cancellationToken, Action<double>? progressCallback);
 }

+ 4 - 2
src/PixiEditor.AnimationRenderer.FFmpeg/FFMpegRenderer.cs

@@ -16,7 +16,7 @@ public class FFMpegRenderer : IAnimationRenderer
     public string OutputFormat { get; set; } = "mp4";
     public VecI Size { get; set; }
 
-    public async Task<bool> RenderAsync(List<Image> rawFrames, string outputPath)
+    public async Task<bool> RenderAsync(List<Image> rawFrames, string outputPath, CancellationToken cancellationToken, Action<double>? progressCallback = null)
     {
         string path = "ThirdParty/{0}/ffmpeg";
 #if WINDOWS
@@ -64,7 +64,9 @@ public class FFMpegRenderer : IAnimationRenderer
                 });
 
             var outputArgs = GetProcessorForFormat(args, outputPath, paletteTempPath);
-            var result = await outputArgs.ProcessAsynchronously();
+            TimeSpan totalTimeSpan = TimeSpan.FromSeconds(frames.Count / (float)FrameRate);
+            var result = await outputArgs.CancellableThrough(cancellationToken)
+                .NotifyOnProgress(progressCallback, totalTimeSpan).ProcessAsynchronously();
             
             if (RequiresPaletteGeneration())
             {

+ 5 - 1
src/PixiEditor.ChangeableDocument/Rendering/DocumentRenderer.cs

@@ -20,7 +20,7 @@ public class DocumentRenderer
     public OneOf<Chunk, EmptyChunk> RenderChunk(VecI chunkPos, ChunkResolution resolution, KeyFrameTime frameTime,
         RectI? globalClippingRect = null)
     {
-        using RenderingContext context = new(frameTime, chunkPos, resolution, Document.Size);
+        RenderingContext context = new(frameTime, chunkPos, resolution, Document.Size);
         try
         {
             RectI? transformedClippingRect = TransformClipRect(globalClippingRect, resolution, chunkPos);
@@ -71,6 +71,10 @@ public class DocumentRenderer
         {
             return new EmptyChunk();
         }
+        finally
+        {
+            context.Dispose();
+        }
     }
 
     public OneOf<Chunk, EmptyChunk> RenderChunk(VecI chunkPos, ChunkResolution resolution,

+ 123 - 109
src/PixiEditor.UI.Common/Controls/ProgressBar.axaml

@@ -1,117 +1,131 @@
 <ResourceDictionary xmlns="https://github.com/avaloniaui"
                     xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                     xmlns:converters="using:Avalonia.Controls.Converters">
-  <Design.PreviewWith>
-    <Border Padding="20">
-      <StackPanel Spacing="10">
-        <ProgressBar VerticalAlignment="Center" IsIndeterminate="True" />
-        <ProgressBar VerticalAlignment="Center" Value="5" Maximum="10" />
-        <ProgressBar VerticalAlignment="Center" Value="50" />
-        <ProgressBar VerticalAlignment="Center" Value="50" Minimum="25" Maximum="75" />
-        <ProgressBar HorizontalAlignment="Left" IsIndeterminate="True" Orientation="Vertical" />
-      </StackPanel>
-    </Border>
-  </Design.PreviewWith>
+    <Design.PreviewWith>
+        <Border Padding="20">
+            <StackPanel Spacing="10">
+                <ProgressBar VerticalAlignment="Center" IsIndeterminate="True" />
+                <ProgressBar VerticalAlignment="Center" Value="5" Maximum="10" />
+                <ProgressBar VerticalAlignment="Center" Value="50" />
+                <ProgressBar VerticalAlignment="Center" Value="50" Minimum="25" Maximum="75" />
+                <ProgressBar HorizontalAlignment="Left" IsIndeterminate="True" Orientation="Vertical" />
+            </StackPanel>
+        </Border>
+    </Design.PreviewWith>
 
-  <converters:StringFormatConverter x:Key="StringFormatConverter" />
+    <converters:StringFormatConverter x:Key="StringFormatConverter" />
 
-  <ControlTheme x:Key="{x:Type ProgressBar}"
-                TargetType="ProgressBar">
-    <Setter Property="Background" Value="{DynamicResource ThemeBackgroundBrush1}" />
-    <Setter Property="Foreground" Value="{DynamicResource ThemeAccentBrush}" />
-    <Setter Property="CornerRadius" Value="{DynamicResource ControlCornerRadius}"/>
-    <Setter Property="Template">
-      <ControlTemplate TargetType="ProgressBar">
-        <Grid>
-          <Border Background="{TemplateBinding Background}"
-                  BorderBrush="{TemplateBinding BorderBrush}"
-                  BorderThickness="{TemplateBinding BorderThickness}"
-                  CornerRadius="{TemplateBinding CornerRadius}">
-            <Panel>
-              <Border Name="PART_Indicator"
-                      Background="{TemplateBinding Foreground}"
-                      CornerRadius="{TemplateBinding CornerRadius}"
-                      IsVisible="{Binding !IsIndeterminate, RelativeSource={RelativeSource TemplatedParent}}" />
-              <Border Name="PART_IndeterminateIndicator"
-                      Background="{TemplateBinding Foreground}"
-                      CornerRadius="{TemplateBinding CornerRadius}"
-                      IsVisible="{Binding IsIndeterminate, RelativeSource={RelativeSource TemplatedParent}}" />
-            </Panel>
-          </Border>
-          <LayoutTransformControl Name="PART_LayoutTransformControl"
-                                  HorizontalAlignment="Center"
-                                  VerticalAlignment="Center"
-                                  IsVisible="{Binding ShowProgressText, RelativeSource={RelativeSource TemplatedParent}}">
-            <TextBlock Foreground="{DynamicResource ThemeForegroundBrush}">
-              <TextBlock.Text>
-                <MultiBinding Converter="{StaticResource StringFormatConverter}">
-                  <TemplateBinding Property="ProgressTextFormat" />
-                  <Binding Path="Value"
-                           RelativeSource="{RelativeSource TemplatedParent}" />
-                  <TemplateBinding Property="Percentage" />
-                  <TemplateBinding Property="Minimum" />
-                  <TemplateBinding Property="Maximum" />
-                </MultiBinding>
-              </TextBlock.Text>
-            </TextBlock>
-          </LayoutTransformControl>
-        </Grid>
-      </ControlTemplate>
-    </Setter>
+    <ControlTheme x:Key="{x:Type ProgressBar}"
+                  TargetType="ProgressBar">
+        <Setter Property="Background" Value="{DynamicResource ThemeBackgroundBrush1}" />
+        <Setter Property="Foreground" Value="{DynamicResource ThemeAccentBrush}" />
+        <Setter Property="CornerRadius" Value="{DynamicResource ControlCornerRadius}" />
+        <Setter Property="Template">
+            <ControlTemplate TargetType="ProgressBar">
+                <Grid>
+                    <Border Background="{TemplateBinding Background}"
+                            BorderBrush="{TemplateBinding BorderBrush}"
+                            BorderThickness="{TemplateBinding BorderThickness}"
+                            CornerRadius="{TemplateBinding CornerRadius}">
+                        <Panel>
+                            <Border Name="PART_Indicator"
+                                    Background="{TemplateBinding Foreground}"
+                                    CornerRadius="{TemplateBinding CornerRadius}"
+                                    IsVisible="{Binding !IsIndeterminate, RelativeSource={RelativeSource TemplatedParent}}" />
+                            <Border Name="PART_IndeterminateIndicator"
+                                    Background="{TemplateBinding Foreground}"
+                                    CornerRadius="{TemplateBinding CornerRadius}"
+                                    IsVisible="{Binding IsIndeterminate, RelativeSource={RelativeSource TemplatedParent}}" />
+                        </Panel>
+                    </Border>
+                    <LayoutTransformControl Name="PART_LayoutTransformControl"
+                                            HorizontalAlignment="Center"
+                                            VerticalAlignment="Center"
+                                            IsVisible="{Binding ShowProgressText, RelativeSource={RelativeSource TemplatedParent}}">
+                        <TextBlock Foreground="{DynamicResource ThemeForegroundBrush}">
+                            <TextBlock.Text>
+                                <MultiBinding Converter="{StaticResource StringFormatConverter}">
+                                    <TemplateBinding Property="ProgressTextFormat" />
+                                    <Binding Path="Value"
+                                             RelativeSource="{RelativeSource TemplatedParent}" />
+                                    <TemplateBinding Property="Percentage" />
+                                    <TemplateBinding Property="Minimum" />
+                                    <TemplateBinding Property="Maximum" />
+                                </MultiBinding>
+                            </TextBlock.Text>
+                        </TextBlock>
+                    </LayoutTransformControl>
+                </Grid>
+            </ControlTemplate>
+        </Setter>
 
-    <Style Selector="^:horizontal /template/ Border#PART_Indicator">
-      <Setter Property="HorizontalAlignment" Value="Left" />
-      <Setter Property="VerticalAlignment" Value="Stretch" />
-    </Style>
-    <Style Selector="^:vertical /template/ Border#PART_Indicator">
-      <Setter Property="HorizontalAlignment" Value="Stretch" />
-      <Setter Property="VerticalAlignment" Value="Bottom" />
-    </Style>
-    <Style Selector="^:horizontal">
-      <Setter Property="MinWidth" Value="200" />
-      <Setter Property="MinHeight" Value="16" />
-    </Style>
-    <Style Selector="^:vertical">
-      <Setter Property="MinWidth" Value="16" />
-      <Setter Property="MinHeight" Value="200" />
-    </Style>
-    <Style Selector="^:vertical /template/ LayoutTransformControl#PART_LayoutTransformControl">
-      <Setter Property="LayoutTransform">
-        <Setter.Value>
-          <RotateTransform Angle="90" />
-        </Setter.Value>
-      </Setter>
-    </Style>
+        <Style Selector="^:horizontal /template/ Border#PART_Indicator">
+            <Setter Property="HorizontalAlignment" Value="Left" />
+            <Setter Property="VerticalAlignment" Value="Stretch" />
+        </Style>
+        <Style Selector="^:vertical /template/ Border#PART_Indicator">
+            <Setter Property="HorizontalAlignment" Value="Stretch" />
+            <Setter Property="VerticalAlignment" Value="Bottom" />
+        </Style>
+        <Style Selector="^:horizontal">
+            <Setter Property="MinWidth" Value="200" />
+            <Setter Property="MinHeight" Value="16" />
+        </Style>
+        <Style Selector="^ /template/ Border#PART_Indicator">
+            <Setter Property="Transitions">
+                <Transitions>
+                    <DoubleTransition Duration="0:0:0.3" Property="Width" />
+                    <DoubleTransition Duration="0:0:0.3" Property="Height" />
+                </Transitions>
+            </Setter>
+        </Style>
+        <Style Selector="^:vertical">
+            <Setter Property="MinWidth" Value="16" />
+            <Setter Property="MinHeight" Value="200" />
+        </Style>
+        <Style Selector="^:vertical /template/ LayoutTransformControl#PART_LayoutTransformControl">
+            <Setter Property="LayoutTransform">
+                <Setter.Value>
+                    <RotateTransform Angle="90" />
+                </Setter.Value>
+            </Setter>
+        </Style>
 
-    <Style Selector="^:horizontal:indeterminate /template/ Border#PART_IndeterminateIndicator">
-        <Style.Animations>
-        <Animation Easing="LinearEasing"
-                   IterationCount="Infinite"
-                   Duration="0:0:3">
-          <KeyFrame Cue="0%">
-            <Setter Property="TranslateTransform.X" Value="{Binding $parent[ProgressBar].TemplateSettings.IndeterminateStartingOffset}" />
-          </KeyFrame>
-          <KeyFrame Cue="100%">
-            <Setter Property="TranslateTransform.X" Value="{Binding $parent[ProgressBar].TemplateSettings.IndeterminateEndingOffset}" />
-          </KeyFrame>
-        </Animation>
-      </Style.Animations>
-      <Setter Property="Width" Value="{Binding TemplateSettings.ContainerWidth, RelativeSource={RelativeSource TemplatedParent}}" />
-    </Style>
-    <Style Selector="^:vertical:indeterminate /template/ Border#PART_IndeterminateIndicator">
-      <Style.Animations>
-        <Animation Easing="LinearEasing"
-                   IterationCount="Infinite"
-                   Duration="0:0:3">
-          <KeyFrame Cue="0%">
-            <Setter Property="TranslateTransform.Y" Value="{Binding $parent[ProgressBar].TemplateSettings.IndeterminateStartingOffset}" />
-          </KeyFrame>
-          <KeyFrame Cue="100%">
-            <Setter Property="TranslateTransform.Y" Value="{Binding $parent[ProgressBar].TemplateSettings.IndeterminateEndingOffset}" />
-          </KeyFrame>
-        </Animation>
-      </Style.Animations>
-      <Setter Property="Height" Value="{Binding TemplateSettings.ContainerWidth, RelativeSource={RelativeSource TemplatedParent}}" />
-    </Style>
-  </ControlTheme>
+        <Style Selector="^:horizontal:indeterminate /template/ Border#PART_IndeterminateIndicator">
+            <Style.Animations>
+                <Animation Easing="LinearEasing"
+                           IterationCount="Infinite"
+                           Duration="0:0:3">
+                    <KeyFrame Cue="0%">
+                        <Setter Property="TranslateTransform.X"
+                                Value="{Binding $parent[ProgressBar].TemplateSettings.IndeterminateStartingOffset}" />
+                    </KeyFrame>
+                    <KeyFrame Cue="100%">
+                        <Setter Property="TranslateTransform.X"
+                                Value="{Binding $parent[ProgressBar].TemplateSettings.IndeterminateEndingOffset}" />
+                    </KeyFrame>
+                </Animation>
+            </Style.Animations>
+            <Setter Property="Width"
+                    Value="{Binding TemplateSettings.ContainerWidth, RelativeSource={RelativeSource TemplatedParent}}" />
+        </Style>
+        <Style Selector="^:vertical:indeterminate /template/ Border#PART_IndeterminateIndicator">
+            <Style.Animations>
+                <Animation Easing="LinearEasing"
+                           IterationCount="Infinite"
+                           Duration="0:0:3">
+                    <KeyFrame Cue="0%">
+                        <Setter Property="TranslateTransform.Y"
+                                Value="{Binding $parent[ProgressBar].TemplateSettings.IndeterminateStartingOffset}" />
+                    </KeyFrame>
+                    <KeyFrame Cue="100%">
+                        <Setter Property="TranslateTransform.Y"
+                                Value="{Binding $parent[ProgressBar].TemplateSettings.IndeterminateEndingOffset}" />
+                    </KeyFrame>
+                </Animation>
+            </Style.Animations>
+            <Setter Property="Height"
+                    Value="{Binding TemplateSettings.ContainerWidth, RelativeSource={RelativeSource TemplatedParent}}" />
+        </Style>
+    </ControlTheme>
 </ResourceDictionary>

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

@@ -687,5 +687,12 @@
   "LERP_NODE": "Lerp",
   "FROM": "From",
   "TO": "To",
-  "TIME": "Time"
+  "TIME": "Time",
+  "WARMING_UP": "Warming up",
+  "RENDERING_FRAME": "Generating Frame {0}/{1}",
+  "RENDERING_VIDEO": "Rendering Video",
+  "FINISHED": "Finished",
+  "GENERATING_SPRITE_SHEET": "Generating Sprite Sheet",
+  "RENDERING_IMAGE": "Rendering Image",
+  "PROGRESS_POPUP_TITLE": "Progress"
 }

+ 13 - 3
src/PixiEditor/Models/Files/ImageFileType.cs

@@ -4,6 +4,7 @@ using PixiEditor.Helpers;
 using PixiEditor.DrawingApi.Core;
 using PixiEditor.DrawingApi.Core.ColorsImpl;
 using PixiEditor.DrawingApi.Core.Surfaces;
+using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Models.IO;
 using PixiEditor.Models.IO.FileEncoders;
 using PixiEditor.Numerics;
@@ -18,17 +19,19 @@ internal abstract class ImageFileType : IoFileType
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Image;
 
     public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document,
-        ExportConfig exportConfig)
+        ExportConfig exportConfig, ExportJob? job)
     {
         Surface finalSurface;
         if (exportConfig.ExportAsSpriteSheet)
         {
-            finalSurface = GenerateSpriteSheet(document, exportConfig);
+            job?.Report(0, new LocalizedString("GENERATING_SPRITE_SHEET"));
+            finalSurface = GenerateSpriteSheet(document, exportConfig, job);
             if (finalSurface == null)
                 return SaveResult.UnknownError;
         }
         else
         {
+            job?.Report(0, new LocalizedString("RENDERING_IMAGE")); 
             var maybeBitmap = document.TryRenderWholeImage(0);
             if (maybeBitmap.IsT0)
                 return SaveResult.ConcurrencyError;
@@ -51,11 +54,13 @@ internal abstract class ImageFileType : IoFileType
         UniversalFileEncoder encoder = new(mappedFormat);
         var result = await TrySaveAs(encoder, pathWithExtension, finalSurface);
         finalSurface.Dispose();
+        
+        job?.Report(1, new LocalizedString("FINISHED"));
 
         return result;
     }
 
-    private Surface? GenerateSpriteSheet(DocumentViewModel document, ExportConfig config)
+    private Surface? GenerateSpriteSheet(DocumentViewModel document, ExportConfig config, ExportJob? job)
     {
         if (document is null)
             return null;
@@ -66,9 +71,14 @@ internal abstract class ImageFileType : IoFileType
         columns = Math.Max(1, columns);
 
         Surface surface = new Surface(new VecI(config.ExportSize.X * columns, config.ExportSize.Y * rows));
+        
+        job?.Report(0, new LocalizedString("RENDERING_FRAME", 0, document.AnimationDataViewModel.FramesCount));
 
         document.RenderFramesProgressive((frame, index) =>
         {
+            job?.CancellationTokenSource.Token.ThrowIfCancellationRequested();
+            
+            job?.Report(index / (double)document.AnimationDataViewModel.FramesCount, new LocalizedString("RENDERING_FRAME", index, document.AnimationDataViewModel.FramesCount));
             int x = index % columns;
             int y = index / columns;
             Surface target = frame;

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

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

+ 3 - 1
src/PixiEditor/Models/Files/PixiFileType.cs

@@ -16,11 +16,13 @@ internal class PixiFileType : IoFileType
 
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Pixi;
 
-    public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config)
+    public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job)
     {
         try
         {
+            job?.Report(0, "Serializing document");
             await Parser.PixiParser.V5.SerializeAsync(document.ToSerializable(), pathWithExtension);
+            job?.Report(1, "Document serialized");
         }
         catch (UnauthorizedAccessException e)
         {

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

@@ -1,5 +1,6 @@
 using PixiEditor.DrawingApi.Core;
 using PixiEditor.DrawingApi.Core.Surfaces.ImageData;
+using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Models.IO;
 using PixiEditor.ViewModels.Document;
 
@@ -10,15 +11,23 @@ internal abstract class VideoFileType : IoFileType
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Video;
 
     public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document,
-        ExportConfig config)
+        ExportConfig config, ExportJob? job)
     {
         if (config.AnimationRenderer is null)
             return SaveResult.UnknownError;
 
         List<Image> frames = new(); 
+        
+        job?.Report(0, new LocalizedString("WARMING_UP"));
+        
+        int frameRendered = 0;
+        int totalFrames = document.AnimationDataViewModel.FramesCount;
 
         document.RenderFrames(frames, surface =>
         {
+            job?.CancellationTokenSource.Token.ThrowIfCancellationRequested();
+            frameRendered++;
+            job?.Report(((double)frameRendered / totalFrames) / 2, new LocalizedString("RENDERING_FRAME", frameRendered, totalFrames));
             if (config.ExportSize != surface.Size)
             {
                 return surface.ResizeNearestNeighbor(config.ExportSize);
@@ -26,8 +35,15 @@ internal abstract class VideoFileType : IoFileType
 
             return surface;
         });
-
-        var result = await config.AnimationRenderer.RenderAsync(frames, pathWithExtension);
+        
+        job?.Report(0.5, new LocalizedString("RENDERING_VIDEO"));
+        CancellationToken token = job?.CancellationTokenSource.Token ?? CancellationToken.None;
+        var result = await config.AnimationRenderer.RenderAsync(frames, pathWithExtension, token, progress =>
+        {
+            job?.Report((progress / 100f) * 0.5f + 0.5, new LocalizedString("RENDERING_VIDEO"));
+        });
+        
+        job?.Report(1, new LocalizedString("FINISHED"));
         
         foreach (var frame in frames)
         {

+ 29 - 0
src/PixiEditor/Models/IO/ExportJob.cs

@@ -0,0 +1,29 @@
+namespace PixiEditor.Models.IO;
+
+public class ExportJob
+{
+    public int Progress { get; private set; }
+    public string Status { get; private set; }
+    public CancellationTokenSource CancellationTokenSource { get; set; }
+    
+    public event Action<int, string> ProgressChanged;
+    public event Action Finished;
+    public event Action Cancelled;
+    
+    public ExportJob()
+    {
+        CancellationTokenSource = new CancellationTokenSource();
+    }
+    
+    public void Finish()
+    {
+        Finished?.Invoke();
+    }
+    
+    public void Report(double progress, string status)
+    {
+        Progress = (int)Math.Clamp(Math.Round(progress * 100), 0, 100);
+        Status = status;
+        ProgressChanged?.Invoke(Progress, Status);
+    }
+}

+ 8 - 6
src/PixiEditor/Models/IO/Exporter.cs

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

+ 24 - 8
src/PixiEditor/ViewModels/SubViewModels/FileViewModel.cs

@@ -1,4 +1,5 @@
 using System.Collections.Generic;
+using System.Diagnostics;
 using System.IO;
 using System.Linq;
 using System.Reflection;
@@ -8,6 +9,7 @@ using Avalonia.Controls;
 using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Input;
 using Avalonia.Platform.Storage;
+using Avalonia.Threading;
 using ChunkyImageLib;
 using Newtonsoft.Json.Linq;
 using PixiEditor.ChangeableDocument.Changeables.Graph;
@@ -341,7 +343,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         string finalPath = null;
         if (asNew || string.IsNullOrEmpty(document.FullFilePath))
         {
-            var result = await Exporter.TrySaveWithDialog(document, ExportConfig.Empty);
+            var result = await Exporter.TrySaveWithDialog(document, ExportConfig.Empty, null);
             if (result.Result == DialogSaveResult.Cancelled)
                 return false;
             if (result.Result != DialogSaveResult.Success)
@@ -355,7 +357,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         }
         else
         {
-            var result = await Exporter.TrySaveAsync(document, document.FullFilePath, ExportConfig.Empty);
+            var result = await Exporter.TrySaveAsync(document, document.FullFilePath, ExportConfig.Empty, null);
             if (result != SaveResult.Success)
             {
                 ShowSaveError((DialogSaveResult)result);
@@ -391,12 +393,26 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
             };
             if (await info.ShowDialog())
             {
-                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.result);
+                ExportJob job = new ExportJob();
+                ProgressDialog dialog = new ProgressDialog(job, MainWindow.Current);
+
+                Task.Run(async () =>
+                {
+                    var result =
+                        await Exporter.TrySaveUsingDataFromDialog(doc, info.FilePath, info.ChosenFormat,
+                            info.ExportConfig,
+                            job);
+                    
+                    if(job?.CancellationTokenSource.IsCancellationRequested == true)
+                        return;
+                    
+                    if (result.result == SaveResult.Success)
+                        IOperatingSystem.Current.OpenFolder(result.finalPath);
+                    else
+                        ShowSaveError((DialogSaveResult)result.result);
+                });
+                
+                await dialog.ShowDialog();
             }
         }
         catch (RecoverableException e)

+ 52 - 21
src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml.cs

@@ -192,6 +192,7 @@ internal partial class ExportFilePopup : PixiEditorPopup
         videoPreviewTimer.Stop();
         videoPreviewTimer.Tick -= OnVideoPreviewTimerOnTick;
         videoPreviewTimer = null;
+        cancellationTokenSource.Cancel();
         cancellationTokenSource.Dispose();
 
         ExportPreview?.Dispose();
@@ -232,7 +233,8 @@ internal partial class ExportFilePopup : PixiEditorPopup
         if (IsVideoExport)
         {
             StartRenderAnimationJob();
-            videoPreviewTimer.Interval = TimeSpan.FromMilliseconds(1000f / document.AnimationDataViewModel.FrameRateBindable);
+            videoPreviewTimer.Interval =
+                TimeSpan.FromMilliseconds(1000f / document.AnimationDataViewModel.FrameRateBindable);
         }
         else
         {
@@ -242,43 +244,67 @@ internal partial class ExportFilePopup : PixiEditorPopup
 
     private void RenderImagePreview()
     {
-        if (IsSpriteSheetExport)
+        try
         {
-            GenerateSpriteSheetPreview();
-        }
-        else
-        {
-            var rendered = document.TryRenderWholeImage(0);
-            if (rendered.IsT1)
+            if (IsSpriteSheetExport)
             {
-                VecI previewSize = CalculatePreviewSize(rendered.AsT1.Size);
-                ExportPreview = rendered.AsT1.ResizeNearestNeighbor(previewSize);
-                rendered.AsT1.Dispose();
+                GenerateSpriteSheetPreview();
+            }
+            else
+            {
+                Task.Run(() =>
+                {
+                    var rendered = document.TryRenderWholeImage(0);
+                    if (rendered.IsT1)
+                    {
+                        VecI previewSize = CalculatePreviewSize(rendered.AsT1.Size);
+                        Dispatcher.UIThread.Post(() =>
+                        {
+                            ExportPreview = rendered.AsT1.ResizeNearestNeighbor(previewSize);
+                            rendered.AsT1.Dispose();
+                        });
+                    }
+                });
             }
         }
-
-        IsGeneratingPreview = false;
+        finally
+        {
+            IsGeneratingPreview = false;
+        }
     }
 
     private void GenerateSpriteSheetPreview()
     {
         int clampedColumns = Math.Max(SpriteSheetColumns, 1);
         int clampedRows = Math.Max(SpriteSheetRows, 1);
-        
+
         VecI previewSize = CalculatePreviewSize(new VecI(SaveWidth * clampedColumns, SaveHeight * clampedRows));
-        VecI singleFrameSize = new VecI(previewSize.X / Math.Max(clampedColumns, 1), previewSize.Y / Math.Max(clampedRows, 1));
+        VecI singleFrameSize = new VecI(previewSize.X / Math.Max(clampedColumns, 1),
+            previewSize.Y / Math.Max(clampedRows, 1));
         if (previewSize != ExportPreview.Size)
         {
             ExportPreview?.Dispose();
             ExportPreview = new Surface(previewSize);
 
-            document.RenderFramesProgressive((frame, index) =>
+            Task.Run(() =>
             {
-                int x = index % clampedColumns;
-                int y = index / clampedColumns;
-                var resized = frame.ResizeNearestNeighbor(new VecI(singleFrameSize.X, singleFrameSize.Y));
-                ExportPreview!.DrawingSurface.Canvas.DrawSurface(resized.DrawingSurface, x * singleFrameSize.X, y * singleFrameSize.Y);
-                resized.Dispose();
+                document.RenderFramesProgressive((frame, index) =>
+                {
+                    if (cancellationTokenSource.IsCancellationRequested)
+                    {
+                        throw new TaskCanceledException();
+                    }
+                    
+                    int x = index % clampedColumns;
+                    int y = index / clampedColumns;
+                    var resized = frame.ResizeNearestNeighbor(new VecI(singleFrameSize.X, singleFrameSize.Y));
+                    Dispatcher.UIThread.Post(() =>
+                    {
+                        ExportPreview!.DrawingSurface.Canvas.DrawSurface(resized.DrawingSurface, x * singleFrameSize.X,
+                            y * singleFrameSize.Y);
+                        resized.Dispose();
+                    });
+                });
             });
         }
     }
@@ -318,6 +344,11 @@ internal partial class ExportFilePopup : PixiEditorPopup
     {
         return Dispatcher.UIThread.Invoke(() =>
         {
+            if (cancellationTokenSource.IsCancellationRequested)
+            {
+                return surface;
+            }
+            
             Surface original = surface;
             if (SaveWidth != surface.Size.X || SaveHeight != surface.Size.Y)
             {

+ 48 - 0
src/PixiEditor/Views/Dialogs/ProgressDialog.cs

@@ -0,0 +1,48 @@
+using Avalonia.Controls;
+using Avalonia.Threading;
+using PixiEditor.Models.Dialogs;
+using PixiEditor.Models.IO;
+
+namespace PixiEditor.Views.Dialogs;
+
+internal class ProgressDialog : CustomDialog
+{
+    public ExportJob Job { get; }
+    
+    public ProgressDialog(ExportJob job, Window ownerWindow) : base(ownerWindow)
+    {
+        Job = job;
+    }
+
+    public override async Task<bool> ShowDialog()
+    {
+        ProgressPopup popup = new ProgressPopup();
+        popup.CancellationToken = Job.CancellationTokenSource;
+        Job.ProgressChanged += (progress, status) =>
+        {
+            Dispatcher.UIThread.Post(() =>
+            {
+                popup.Progress = progress;
+                popup.Status = status;
+            });
+        };
+        
+        Job.Finished += () =>
+        {
+            Dispatcher.UIThread.Post(() =>
+            {
+                popup.Close();
+            });
+        };
+        
+        Job.Cancelled += () =>
+        {
+            Dispatcher.UIThread.Post(() =>
+            {
+                popup.Close();
+            });
+        };
+        
+        return await popup.ShowDialog<bool>(OwnerWindow);
+    }
+}

+ 17 - 0
src/PixiEditor/Views/Dialogs/ProgressPopup.axaml

@@ -0,0 +1,17 @@
+<dialogs:PixiEditorPopup xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+        xmlns:dialogs="clr-namespace:PixiEditor.Views.Dialogs"
+        xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
+        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+        x:Class="PixiEditor.Views.Dialogs.ProgressPopup"
+        CanMinimize="False"
+        CanResize="False"
+        ui:Translator.Key="PROGRESS_POPUP_TITLE"
+        Width="400" Height="150">
+    <StackPanel DataContext="{Binding RelativeSource={RelativeSource AncestorType=dialogs:ProgressPopup, Mode=FindAncestor}}">
+        <TextBlock ui:Translator.Key="{Binding Status}" Margin="10" Classes="h3"/>
+        <ProgressBar VerticalAlignment="Center" ShowProgressText="True" Value="{Binding Progress}" Maximum="100" Margin="10"/>
+    </StackPanel>
+</dialogs:PixiEditorPopup>

+ 51 - 0
src/PixiEditor/Views/Dialogs/ProgressPopup.axaml.cs

@@ -0,0 +1,51 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PixiEditor.Views.Dialogs;
+
+public partial class ProgressPopup : PixiEditorPopup
+{
+    public static readonly StyledProperty<double> ProgressProperty = AvaloniaProperty.Register<ProgressPopup, double>(
+        nameof(Progress));
+
+    public static readonly StyledProperty<string> StatusProperty = AvaloniaProperty.Register<ProgressPopup, string>(
+        nameof(Status));
+
+    public static readonly StyledProperty<CancellationTokenSource> CancellationTokenProperty = AvaloniaProperty.Register<ProgressPopup, CancellationTokenSource>(
+        nameof(CancellationToken));
+
+    public CancellationTokenSource CancellationToken
+    {
+        get => GetValue(CancellationTokenProperty);
+        set => SetValue(CancellationTokenProperty, value);
+    }
+
+    public string Status
+    {
+        get => GetValue(StatusProperty);
+        set => SetValue(StatusProperty, value);
+    }
+
+    public double Progress
+    {
+        get => GetValue(ProgressProperty);
+        set => SetValue(ProgressProperty, value);
+    }
+
+    protected override void OnClosing(WindowClosingEventArgs e)
+    {
+        base.OnClosing(e);
+        CancellationToken.Cancel();
+        if(!e.IsProgrammatic)
+        {
+            e.Cancel = true;
+        }
+    }
+
+    public ProgressPopup()
+    {
+        InitializeComponent();
+    }
+}
+