Przeglądaj źródła

Merge branch 'direct-rendering' into FullGpuDrawingBackend

flabbet 11 miesięcy temu
rodzic
commit
fd9b353ad4
38 zmienionych plików z 752 dodań i 377 usunięć
  1. 1 1
      src/PixiEditor.AnimationRenderer.Core/IAnimationRenderer.cs
  2. 7 3
      src/PixiEditor.AnimationRenderer.FFmpeg/FFMpegRenderer.cs
  3. 12 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Context/FuncContext.cs
  4. 21 11
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ModifyImageLeftNode.cs
  5. 49 21
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ModifyImageRightNode.cs
  6. 23 17
      src/PixiEditor.ChangeableDocument/Rendering/DocumentRenderer.cs
  7. 8 0
      src/PixiEditor.ChangeableDocument/Rendering/RenderingContext.cs
  8. 1 0
      src/PixiEditor.DrawingApi.Core/Bridge/Operations/ISurfaceImplementation.cs
  9. 5 0
      src/PixiEditor.DrawingApi.Core/Surfaces/DrawingSurface.cs
  10. 19 66
      src/PixiEditor.DrawingApi.Core/Texture.cs
  11. 1 1
      src/PixiEditor.DrawingApi.Skia/Implementations/SKObjectImplementation.cs
  12. 16 2
      src/PixiEditor.DrawingApi.Skia/Implementations/SkiaCanvasImplementation.cs
  13. 10 0
      src/PixiEditor.DrawingApi.Skia/Implementations/SkiaSurfaceImplementation.cs
  14. 123 109
      src/PixiEditor.UI.Common/Controls/ProgressBar.axaml
  15. 8 1
      src/PixiEditor/Data/Localization/Languages/en.json
  16. 1 1
      src/PixiEditor/Helpers/MarkupExtensions/EnumExtension.cs
  17. 16 9
      src/PixiEditor/Models/DocumentModels/ActionAccumulator.cs
  18. 17 6
      src/PixiEditor/Models/Files/ImageFileType.cs
  19. 1 1
      src/PixiEditor/Models/Files/IoFileType.cs
  20. 3 1
      src/PixiEditor/Models/Files/PixiFileType.cs
  21. 19 3
      src/PixiEditor/Models/Files/VideoFileType.cs
  22. 29 0
      src/PixiEditor/Models/IO/ExportJob.cs
  23. 8 6
      src/PixiEditor/Models/IO/Exporter.cs
  24. 63 34
      src/PixiEditor/Models/Rendering/CanvasUpdater.cs
  25. 6 6
      src/PixiEditor/Models/Rendering/MemberPreviewUpdater.cs
  26. 21 16
      src/PixiEditor/Styles/Templates/KeyFrame.axaml
  27. 2 2
      src/PixiEditor/ViewModels/Dock/LayoutManager.cs
  28. 17 3
      src/PixiEditor/ViewModels/Document/DocumentViewModel.cs
  29. 21 8
      src/PixiEditor/ViewModels/SubViewModels/FileViewModel.cs
  30. 62 24
      src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml.cs
  31. 48 0
      src/PixiEditor/Views/Dialogs/ProgressDialog.cs
  32. 17 0
      src/PixiEditor/Views/Dialogs/ProgressPopup.axaml
  33. 51 0
      src/PixiEditor/Views/Dialogs/ProgressPopup.axaml.cs
  34. 3 1
      src/PixiEditor/Views/Main/Tools/ToolPickerButton.axaml
  35. 2 2
      src/PixiEditor/Views/Nodes/NodeView.cs
  36. 30 11
      src/PixiEditor/Views/Rendering/Scene.cs
  37. 10 11
      src/PixiEditor/Views/Visuals/TextureControl.cs
  38. 1 0
      src/PixiEditor/Views/Visuals/TextureImage.cs

+ 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);
 }

+ 7 - 3
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())
             {
@@ -120,7 +122,9 @@ public class FFMpegRenderer : IAnimationRenderer
             .OutputToFile(outputPath, true, options =>
             {
                 options.WithFramerate(FrameRate)
-                    .WithConstantRateFactor(21)
+                    .WithConstantRateFactor(18)
+                    .WithVideoBitrate(1800)
+                    .WithVideoCodec("mpeg4")
                     .ForcePixelFormat("yuv420p");
             });
     }

+ 12 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Context/FuncContext.cs

@@ -1,4 +1,5 @@
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Rendering;
 using PixiEditor.Numerics;
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph.Context;
@@ -10,6 +11,7 @@ public class FuncContext
     public VecD Position { get; private set; }
     public VecI Size { get; private set; }
     public bool HasContext { get; private set; }
+    public RenderingContext RenderingContext { get; set; }
 
     public void ThrowOnMissingContext()
     {
@@ -19,6 +21,16 @@ public class FuncContext
         }
     }
 
+    public FuncContext()
+    {
+        
+    }
+    
+    public FuncContext(RenderingContext renderingContext)
+    {
+        RenderingContext = renderingContext;
+    }
+
     public void UpdateContext(VecD position, VecI size)
     {
         Position = position;

+ 21 - 11
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ModifyImageLeftNode.cs

@@ -1,4 +1,5 @@
-using PixiEditor.ChangeableDocument.Changeables.Animations;
+using System.Collections.Concurrent;
+using PixiEditor.ChangeableDocument.Changeables.Animations;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Context;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using PixiEditor.ChangeableDocument.Rendering;
@@ -13,19 +14,19 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 [PairNode(typeof(ModifyImageRightNode), "ModifyImageZone", true)]
 public class ModifyImageLeftNode : Node
 {
-    private Pixmap? pixmap;
-
-    public InputProperty<Texture?> Image { get; }
+    public InputProperty<Surface?> Image { get; }
     
     public FuncOutputProperty<VecD> Coordinate { get; }
     
     public FuncOutputProperty<Color> Color { get; }
 
     public override string DisplayName { get; set; } = "MODIFY_IMAGE_LEFT_NODE";
+    
+    private ConcurrentDictionary<RenderingContext, Pixmap> pixmapCache = new();
 
     public ModifyImageLeftNode()
     {
-        Image = CreateInput<Texture>(nameof(Surface), "IMAGE", null);
+        Image = CreateInput<Surface>(nameof(Surface), "IMAGE", null);
         Coordinate = CreateFuncOutput(nameof(Coordinate), "UV", ctx => ctx.Position);
         Color = CreateFuncOutput(nameof(Color), "COLOR", GetColor);
     }
@@ -33,26 +34,35 @@ public class ModifyImageLeftNode : Node
     private Color GetColor(FuncContext context)
     {
         context.ThrowOnMissingContext();
+
+        var targetPixmap = pixmapCache[context.RenderingContext];
         
-        if (pixmap == null)
+        if (targetPixmap == null)
             return new Color();
         
         var x = context.Position.X * context.Size.X;
         var y = context.Position.Y * context.Size.Y;
         
-        return pixmap.GetPixelColor((int)x, (int)y);
+        return targetPixmap.GetPixelColor((int)x, (int)y);
     }
 
-    internal void PreparePixmap()
+    internal void PreparePixmap(RenderingContext forContext)
     {
-        pixmap = Image.Value?.PeekReadOnlyPixels();
+        pixmapCache[forContext] = Image.Value?.DrawingSurface.Snapshot().PeekPixels();
+    }
+    
+    internal void DisposePixmap(RenderingContext forContext)
+    {
+        if (pixmapCache.TryRemove(forContext, out var targetPixmap))
+        {
+            targetPixmap?.Dispose();
+        }
     }
 
-    protected override Texture? OnExecute(RenderingContext context)
+    protected override Surface? OnExecute(RenderingContext context)
     {
         return Image.Value;
     }
 
-
     public override Node CreateCopy() => new ModifyImageLeftNode();
 }

+ 49 - 21
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ModifyImageRightNode.cs

@@ -22,18 +22,20 @@ public class ModifyImageRightNode : Node, IPairNodeEnd
     public FuncInputProperty<VecD> Coordinate { get; }
     public FuncInputProperty<Color> Color { get; }
 
-    public OutputProperty<Texture> Output { get; }
+    public OutputProperty<Surface> Output { get; }
 
     public override string DisplayName { get; set; } = "MODIFY_IMAGE_RIGHT_NODE";
 
+    private Surface surface;
+
     public ModifyImageRightNode()
     {
         Coordinate = CreateFuncInput(nameof(Coordinate), "UV", new VecD());
         Color = CreateFuncInput(nameof(Color), "COLOR", new Color());
-        Output = CreateOutput<Texture>(nameof(Output), "OUTPUT", null);
+        Output = CreateOutput<Surface>(nameof(Output), "OUTPUT", null);
     }
 
-    protected override Texture? OnExecute(RenderingContext renderingContext)
+    protected override Surface? OnExecute(RenderingContext renderingContext)
     {
         if (StartNode == null)
         {
@@ -49,34 +51,60 @@ public class ModifyImageRightNode : Node, IPairNodeEnd
         {
             return null;
         }
-
-        startNode.PreparePixmap();
-
+        
         var width = size.X;
         var height = size.Y;
+        
+        surface = new Surface(size);
+
+        startNode.PreparePixmap(renderingContext);
+        
+        using Pixmap targetPixmap = surface.PeekPixels();
 
-        var surface = new Texture(size);
+        ModifyImageInParallel(renderingContext, targetPixmap, width, height);
+        
+        startNode.DisposePixmap(renderingContext);
+
+        Output.Value = surface;
+
+        return Output.Value;
+    }
 
-        var context = new FuncContext();
+    private unsafe void ModifyImageInParallel(RenderingContext renderingContext, Pixmap targetPixmap, int width, int height)
+    {
+        int threads = Environment.ProcessorCount;
+        int chunkHeight = height / threads;
 
-        for (int y = 0; y < height; y++)
+        Parallel.For(0, threads, i =>
         {
-            for (int x = 0; x < width; x++)
+            FuncContext context = new(renderingContext);
+            
+            int startY = i * chunkHeight;
+            int endY = (i + 1) * chunkHeight;
+            if (i == threads - 1)
             {
-                context.UpdateContext(new VecD((double)x / width, (double)y / height), new VecI(width, height));
-                var uv = Coordinate.Value(context);
-                context.UpdateContext(uv, new VecI(width, height));
-                var color = Color.Value(context);
-                
-                drawingPaint.Color = color;
-
-                surface.Surface.Canvas.DrawPixel(x, y, drawingPaint);
+                endY = height;
             }
-        }
 
-        Output.Value = surface;
+            Half* drawArray = (Half*)targetPixmap.GetPixels();
 
-        return Output.Value;
+            for (int y = startY; y < endY; y++)
+            {
+                for (int x = 0; x < width; x++)
+                {
+                    context.UpdateContext(new VecD((double)x / width, (double)y / height), new VecI(width, height));
+                    var coordinate = Coordinate.Value(context);
+                    context.UpdateContext(coordinate, new VecI(width, height));
+                    
+                    var color = Color.Value(context);
+                    ulong colorBits = color.ToULong();
+                    
+                    int pixelOffset = (y * width + x) * 4;
+                    Half* drawPixel = drawArray + pixelOffset;
+                    *(ulong*)drawPixel = colorBits;
+                }
+            }
+        });
     }
 
     private void FindStartNode()

+ 23 - 17
src/PixiEditor.ChangeableDocument/Rendering/DocumentRenderer.cs

@@ -20,12 +20,12 @@ 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);
 
-            Texture? evaluated = Document.NodeGraph.Execute(context);
+            Surface? evaluated = Document.NodeGraph.Execute(context);
             if (evaluated is null)
             {
                 return new EmptyChunk();
@@ -33,12 +33,12 @@ public class DocumentRenderer
 
             Chunk chunk = Chunk.Create(resolution);
 
-            chunk.Surface.Surface.Canvas.Save();
-            chunk.Surface.Surface.Canvas.Clear();
+            chunk.Surface.DrawingSurface.Canvas.Save();
+            chunk.Surface.DrawingSurface.Canvas.Clear();
 
             if (transformedClippingRect is not null)
             {
-                chunk.Surface.Surface.Canvas.ClipRect((RectD)transformedClippingRect);
+                chunk.Surface.DrawingSurface.Canvas.ClipRect((RectD)transformedClippingRect);
             }
 
             VecD pos = chunkPos;
@@ -59,11 +59,13 @@ public class DocumentRenderer
                 return new EmptyChunk();
             }
 
-            using var chunkSnapshot = evaluated.Surface.Snapshot((RectI)sourceRect);
+            using var chunkSnapshot = evaluated.DrawingSurface.Snapshot((RectI)sourceRect);
+            
+            if(context.IsDisposed) return new EmptyChunk();
 
-            chunk.Surface.Surface.Canvas.DrawImage(chunkSnapshot, 0, 0, context.ReplacingPaintWithOpacity);
+            chunk.Surface.DrawingSurface.Canvas.DrawImage(chunkSnapshot, 0, 0, context.ReplacingPaintWithOpacity);
 
-            chunk.Surface.Surface.Canvas.Restore();
+            chunk.Surface.DrawingSurface.Canvas.Restore();
 
             return chunk;
         }
@@ -71,6 +73,10 @@ public class DocumentRenderer
         {
             return new EmptyChunk();
         }
+        finally
+        {
+            context.Dispose();
+        }
     }
 
     public OneOf<Chunk, EmptyChunk> RenderChunk(VecI chunkPos, ChunkResolution resolution,
@@ -81,7 +87,7 @@ public class DocumentRenderer
         {
             RectI? transformedClippingRect = TransformClipRect(globalClippingRect, resolution, chunkPos);
 
-            Texture? evaluated = node.Execute(context);
+            Surface? evaluated = node.Execute(context);
             if (evaluated is null)
             {
                 return new EmptyChunk();
@@ -112,7 +118,7 @@ public class DocumentRenderer
         NodeGraph membersOnlyGraph = ConstructMembersOnlyGraph(layersToCombine, Document.NodeGraph);
         try
         {
-            Texture? evaluated = membersOnlyGraph.Execute(context);
+            Surface? evaluated = membersOnlyGraph.Execute(context);
             if (evaluated is null)
             {
                 return new EmptyChunk();
@@ -147,7 +153,7 @@ public class DocumentRenderer
             }
         });
 
-        IInputProperty<Texture> lastInput = outputNode.Input;
+        IInputProperty<Surface> lastInput = outputNode.Input;
 
         foreach (var layer in layersInOrder)
         {
@@ -162,28 +168,28 @@ public class DocumentRenderer
     }
 
     private static OneOf<Chunk, EmptyChunk> ChunkFromResult(ChunkResolution resolution,
-        RectI? transformedClippingRect, Texture evaluated,
+        RectI? transformedClippingRect, Surface evaluated,
         RenderingContext context)
     {
         Chunk chunk = Chunk.Create(resolution);
 
-        chunk.Surface.Surface.Canvas.Save();
-        chunk.Surface.Surface.Canvas.Clear();
+        chunk.Surface.DrawingSurface.Canvas.Save();
+        chunk.Surface.DrawingSurface.Canvas.Clear();
 
         int x = 0;
         int y = 0;
 
         if (transformedClippingRect is not null)
         {
-            chunk.Surface.Surface.Canvas.ClipRect((RectD)transformedClippingRect);
+            chunk.Surface.DrawingSurface.Canvas.ClipRect((RectD)transformedClippingRect);
             x = transformedClippingRect.Value.X;
             y = transformedClippingRect.Value.Y;
         }
 
-        chunk.Surface.Surface.Canvas.DrawSurface(evaluated.Surface, x, y,
+        chunk.Surface.DrawingSurface.Canvas.DrawSurface(evaluated.DrawingSurface, x, y,
             context.ReplacingPaintWithOpacity);
 
-        chunk.Surface.Surface.Canvas.Restore();
+        chunk.Surface.DrawingSurface.Canvas.Restore();
 
         return chunk;
     }

+ 8 - 0
src/PixiEditor.ChangeableDocument/Rendering/RenderingContext.cs

@@ -20,6 +20,8 @@ public class RenderingContext : IDisposable
     public ChunkResolution ChunkResolution { get; }
     public VecI DocumentSize { get; set; }
 
+    public bool IsDisposed { get; private set; }
+    
     public RenderingContext(KeyFrameTime frameTime, VecI chunkToUpdate, ChunkResolution chunkResolution, VecI docSize)
     {
         FrameTime = frameTime;
@@ -55,6 +57,12 @@ public class RenderingContext : IDisposable
 
     public void Dispose()
     {
+        if (IsDisposed)
+        {
+            return;
+        }
+        
+        IsDisposed = true;
         BlendModePaint.Dispose();
         BlendModeOpacityPaint.Dispose();
         ReplacingPaintWithOpacity.Dispose();

+ 1 - 0
src/PixiEditor.DrawingApi.Core/Bridge/Operations/ISurfaceImplementation.cs

@@ -17,5 +17,6 @@ public interface ISurfaceImplementation
     public void Dispose(DrawingSurface drawingSurface);
     public object GetNativeSurface(IntPtr objectPointer);
     public void Flush(DrawingSurface drawingSurface);
+    public DrawingSurface CreateFromNative(object native);
 }
 

+ 5 - 0
src/PixiEditor.DrawingApi.Core/Surfaces/DrawingSurface.cs

@@ -80,5 +80,10 @@ namespace PixiEditor.DrawingApi.Core.Surfaces
         {
             DrawingBackendApi.Current.SurfaceImplementation.Flush(this);
         }
+
+        public static DrawingSurface CreateFromNative(object native)
+        {
+            return DrawingBackendApi.Current.SurfaceImplementation.CreateFromNative(native);
+        }
     }
 }

+ 19 - 66
src/PixiEditor.DrawingApi.Core/Texture.cs

@@ -11,7 +11,7 @@ namespace PixiEditor.DrawingApi.Core;
 public class Texture : IDisposable
 {
     public VecI Size { get; }
-    public DrawingSurface Surface { get; }
+    public DrawingSurface Surface { get; private set; }
 
     public event SurfaceChangedEventHandler? Changed;
 
@@ -20,9 +20,6 @@ public class Texture : IDisposable
     private bool pixmapUpToDate;
     private Pixmap pixmap;
 
-    private Paint nearestNeighborReplacingPaint =
-        new() { BlendMode = BlendMode.Src, FilterQuality = FilterQuality.None };
-
     public Texture(VecI size)
     {
         Size = size;
@@ -36,23 +33,19 @@ public class Texture : IDisposable
         Surface.Changed += SurfaceOnChanged;
     }
 
-    public Texture(Texture createFrom)
+    internal Texture(DrawingSurface surface)
     {
-        Size = createFrom.Size;
-
-        Surface =
-            DrawingSurface.Create(
-                new ImageInfo(Size.X, Size.Y, ColorType.RgbaF16, AlphaType.Premul, ColorSpace.CreateSrgb())
-                {
-                    GpuBacked = true
-                });
-
-        Surface.Canvas.DrawSurface(createFrom.Surface, 0, 0);
+        Surface = surface;
+        Surface.Changed += SurfaceOnChanged;
+    }
+    
+    ~Texture()
+    {
+       Surface.Changed -= SurfaceOnChanged;
     }
 
     private void SurfaceOnChanged(RectD? changedRect)
     {
-        pixmapUpToDate = false;
         Changed?.Invoke(changedRect);
     }
 
@@ -113,10 +106,10 @@ public class Texture : IDisposable
         return newTexture;
     }
 
-    public Color GetSRGBPixel(VecI vecI)
+    public Color? GetSRGBPixel(VecI vecI)
     {
         if (vecI.X < 0 || vecI.X >= Size.X || vecI.Y < 0 || vecI.Y >= Size.Y)
-            return Color.Empty;
+            return null;
 
         if (!pixmapUpToDate)
         {
@@ -127,6 +120,11 @@ public class Texture : IDisposable
         return pixmap.GetPixelColor(vecI);
     }
 
+    public void AddDirtyRect(RectI dirtyRect)
+    {
+        Changed?.Invoke(new RectD(dirtyRect.X, dirtyRect.Y, dirtyRect.Width, dirtyRect.Height));
+    }
+
     public void Dispose()
     {
         if (IsDisposed)
@@ -137,54 +135,9 @@ public class Texture : IDisposable
         Surface.Dispose();
     }
 
-    public Pixmap? PeekReadOnlyPixels()
-    {
-        if (pixmapUpToDate)
-        {
-            return pixmap;
-        }
-
-        pixmap = Surface.PeekPixels();
-        pixmapUpToDate = true;
-
-        return pixmap;
-    }
-
-    public void CopyTo(Texture destination)
-    {
-        destination.Surface.Canvas.DrawSurface(Surface, 0, 0);
-    }
-
-    public unsafe bool IsFullyTransparent()
+    public static Texture FromExisting(DrawingSurface drawingSurface)
     {
-        ulong* ptr = (ulong*)PeekReadOnlyPixels().GetPixels();
-        for (int i = 0; i < Size.X * Size.Y; i++)
-        {
-            // ptr[i] actually contains 4 16-bit floats. We only care about the first one which is alpha.
-            // An empty pixel can have alpha of 0 or -0 (not sure if -0 actually ever comes up). 0 in hex is 0x0, -0 in hex is 0x8000
-            if ((ptr[i] & 0x1111_0000_0000_0000) != 0 && (ptr[i] & 0x1111_0000_0000_0000) != 0x8000_0000_0000_0000)
-                return false;
-        }
-
-        return true;
-    }
-
-    public void DrawBytes(VecI surfaceSize, byte[] pixels, ColorType color, AlphaType alphaType)
-    {
-        if (surfaceSize != Size)
-            throw new ArgumentException("Surface size must match the size of the byte array");
-
-        using Image image = Image.FromPixels(new ImageInfo(Size.X, Size.Y, color, alphaType, ColorSpace.CreateSrgb()),
-            pixels);
-        Surface.Canvas.DrawImage(image, 0, 0);
-    }
-
-    public Texture ResizeNearestNeighbor(VecI newSize)
-    {
-        using Image image = Surface.Snapshot();
-        Texture newSurface = new(newSize);
-        newSurface.Surface.Canvas.DrawImage(image, new RectD(0, 0, newSize.X, newSize.Y),
-            nearestNeighborReplacingPaint);
-        return newSurface;
+        Texture texture = new(drawingSurface);
+        return texture;
     }
 }

+ 1 - 1
src/PixiEditor.DrawingApi.Skia/Implementations/SKObjectImplementation.cs

@@ -17,7 +17,7 @@ namespace PixiEditor.DrawingApi.Skia.Implementations
         
         public T this[IntPtr objPtr]
         {
-            get => ManagedInstances[objPtr];
+            get => ManagedInstances.TryGetValue(objPtr, out var instance) ? instance : throw new ObjectDisposedException(nameof(objPtr));
             set => ManagedInstances[objPtr] = value;
         }
     }

+ 16 - 2
src/PixiEditor.DrawingApi.Skia/Implementations/SkiaCanvasImplementation.cs

@@ -57,8 +57,22 @@ namespace PixiEditor.DrawingApi.Skia.Implementations
 
         public void DrawImage(IntPtr objPtr, Image image, int x, int y, Paint paint)
         {
-            var canvas = ManagedInstances[objPtr];
-            canvas.DrawImage(_imageImpl.ManagedInstances[image.ObjectPointer], x, y, _paintImpl.ManagedInstances[paint.ObjectPointer]);
+            if(!ManagedInstances.TryGetValue(objPtr, out var canvas))
+            {
+                throw new ObjectDisposedException(nameof(canvas));
+            }
+            
+            if (!_paintImpl.ManagedInstances.TryGetValue(paint.ObjectPointer, out var skPaint))
+            {
+                throw new ObjectDisposedException(nameof(paint));
+            }
+            
+            if(!_imageImpl.ManagedInstances.TryGetValue(image.ObjectPointer, out var img))
+            {
+                throw new ObjectDisposedException(nameof(image));
+            }
+            
+            canvas.DrawImage(img, x, y, skPaint);
         }
 
         public int Save(IntPtr objPtr)

+ 10 - 0
src/PixiEditor.DrawingApi.Skia/Implementations/SkiaSurfaceImplementation.cs

@@ -156,5 +156,15 @@ namespace PixiEditor.DrawingApi.Skia.Implementations
         {
             ManagedInstances[drawingSurface.ObjectPointer].Flush(true, true);
         }
+
+        public DrawingSurface CreateFromNative(object native)
+        {
+            if (native is not SKSurface skSurface)
+            {
+                throw new ArgumentException("Native object is not of type SKSurface");
+            }
+
+            return CreateDrawingSurface(skSurface);
+        }
     }
 }

+ 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"
 }

+ 1 - 1
src/PixiEditor/Helpers/MarkupExtensions/EnumExtension.cs

@@ -28,7 +28,7 @@ internal class EnumExtension : MarkupExtension
         }
     }
 
-    public override object ProvideValue(IServiceProvider serviceProvider) // or IXamlServiceProvider for UWP and WinUI
+    public override object ProvideValue(IServiceProvider serviceProvider)
     {
         return Enum.GetValues(EnumType);
     }

+ 16 - 9
src/PixiEditor/Models/DocumentModels/ActionAccumulator.cs

@@ -2,9 +2,11 @@
 using System.Linq;
 using Avalonia.Platform;
 using Avalonia.Threading;
+using ChunkyImageLib.DataHolders;
 using PixiEditor.ChangeableDocument.Actions;
 using PixiEditor.ChangeableDocument.Actions.Undo;
 using PixiEditor.ChangeableDocument.ChangeInfos;
+using PixiEditor.DrawingApi.Core;
 using PixiEditor.DrawingApi.Core.Numerics;
 using PixiEditor.Helpers;
 using PixiEditor.Models.DocumentPassthroughActions;
@@ -12,6 +14,7 @@ using PixiEditor.Models.Handlers;
 using PixiEditor.Models.Rendering;
 using PixiEditor.Models.Rendering.RenderInfos;
 using PixiEditor.Numerics;
+using PixiEditor.Views.Rendering;
 
 namespace PixiEditor.Models.DocumentModels;
 #nullable enable
@@ -33,6 +36,12 @@ internal class ActionAccumulator
 
         canvasUpdater = new(doc, internals);
         previewUpdater = new(doc, internals);
+        Scene.Paint += SceneOnPaint;
+    }
+
+    private void SceneOnPaint(Texture obj)
+    {
+        canvasUpdater.Render(obj, ChunkResolution.Full); 
     }
 
     public void AddFinishedActions(params IAction[] actions)
@@ -89,11 +98,10 @@ internal class ActionAccumulator
             if (undoBoundaryPassed)
                 internals.Updater.AfterUndoBoundaryPassed();
 
-            // update the contents of the bitmaps
             var affectedAreas = new AffectedAreasGatherer(document.AnimationHandler.ActiveFrameTime, internals.Tracker, optimizedChanges);
             List<IRenderInfo> renderResult = new();
-            renderResult.AddRange(await canvasUpdater.UpdateGatheredChunks(affectedAreas, undoBoundaryPassed || viewportRefreshRequest));
-            //renderResult.AddRange(await previewUpdater.UpdateGatheredChunks(affectedAreas, undoBoundaryPassed));
+            //await canvasUpdater.UpdateGatheredChunks(affectedAreas, undoBoundaryPassed || viewportRefreshRequest);
+            renderResult.AddRange(await previewUpdater.UpdateGatheredChunks(affectedAreas, undoBoundaryPassed));
 
             if (undoBoundaryPassed)
             {
@@ -139,7 +147,7 @@ internal class ActionAccumulator
                         RectI finalRect = new RectI(VecI.Zero, new(bitmap.Size.X, bitmap.Size.Y));
 
                         RectI dirtyRect = new RectI(info.Pos, info.Size).Intersect(finalRect);
-                        // bitmap.AddDirtyRect(dirtyRect);
+                        bitmap.AddDirtyRect(dirtyRect);
                     }
                     break;
                 case PreviewDirty_RenderInfo info:
@@ -147,8 +155,7 @@ internal class ActionAccumulator
                         var bitmap = document.StructureHelper.Find(info.GuidValue)?.PreviewSurface;
                         if (bitmap is null)
                             continue;
-                        //TODO: Implement dirty rects
-                        // bitmap.AddDirtyRect(new RectI(0, 0, bitmap.Size.X, bitmap.Size.Y));
+                        bitmap.AddDirtyRect(new RectI(0, 0, bitmap.Size.X, bitmap.Size.Y));
                     }
                     break;
                 case MaskPreviewDirty_RenderInfo info:
@@ -156,12 +163,12 @@ internal class ActionAccumulator
                         var bitmap = document.StructureHelper.Find(info.GuidValue)?.MaskPreviewSurface;
                         if (bitmap is null)
                             continue;
-                        //bitmap.AddDirtyRect(new RectI(0, 0, bitmap.Size.X, bitmap.Size.Y));
+                        bitmap.AddDirtyRect(new RectI(0, 0, bitmap.Size.X, bitmap.Size.Y));
                     }
                     break;
                 case CanvasPreviewDirty_RenderInfo:
                     {
-                        //document.PreviewSurface.AddDirtyRect(new RectI(0, 0, document.PreviewSurface.Size.X, document.PreviewSurface.Size.Y));
+                        document.PreviewSurface.AddDirtyRect(new RectI(0, 0, document.PreviewSurface.Size.X, document.PreviewSurface.Size.Y));
                     }
                     break;
                 case NodePreviewDirty_RenderInfo info:
@@ -169,7 +176,7 @@ internal class ActionAccumulator
                         var node = document.StructureHelper.Find(info.NodeId);
                         if (node is null || node.PreviewSurface is null)
                             continue;
-                        //node.PreviewSurface.AddDirtyRect(new RectI(0, 0, node.PreviewSurface.Size.X, node.PreviewSurface.Size.Y));
+                        node.PreviewSurface.AddDirtyRect(new RectI(0, 0, node.PreviewSurface.Size.X, node.PreviewSurface.Size.Y));
                     }
                     break;
             }

+ 17 - 6
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,21 +71,27 @@ 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) =>
+        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;
             if (config.ExportSize != frame.Size)
             {
-               target =
+                target =
                     frame.ResizeNearestNeighbor(new VecI(config.ExportSize.X, config.ExportSize.Y));
             }
             
             surface!.DrawingSurface.Canvas.DrawSurface(target.DrawingSurface, x * config.ExportSize.X, y * config.ExportSize.Y);
             target.Dispose();
-        });
+        }, job?.CancellationTokenSource.Token ?? CancellationToken.None);
 
         return surface;
     }

+ 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)

+ 63 - 34
src/PixiEditor/Models/Rendering/CanvasUpdater.cs

@@ -13,6 +13,7 @@ using PixiEditor.Models.DocumentModels;
 using PixiEditor.Models.Handlers;
 using PixiEditor.Models.Rendering.RenderInfos;
 using PixiEditor.Numerics;
+using PixiEditor.Views.Rendering;
 
 namespace PixiEditor.Models.Rendering;
 #nullable enable
@@ -21,7 +22,7 @@ internal class CanvasUpdater
     private readonly IDocument doc;
     private readonly DocumentInternalParts internals;
 
-    private static readonly Paint ReplacingPaint = new() { BlendMode = BlendMode.Src };
+    private static readonly Paint ReplacingPaint = new() { BlendMode = BlendMode.SrcOver };
 
     private static readonly Paint ClearPaint = new()
     {
@@ -52,6 +53,15 @@ internal class CanvasUpdater
             [ChunkResolution.Eighth] = new()
         };
 
+    private Dictionary<ChunkResolution, HashSet<VecI>> nextRepaint =
+        new()
+        {
+            [ChunkResolution.Full] = new(),
+            [ChunkResolution.Half] = new(),
+            [ChunkResolution.Quarter] = new(),
+            [ChunkResolution.Eighth] = new()
+        };
+
 
     public CanvasUpdater(IDocument doc, DocumentInternalParts internals)
     {
@@ -62,19 +72,44 @@ internal class CanvasUpdater
     /// <summary>
     /// Don't call this outside ActionAccumulator
     /// </summary>
-    public async Task<List<IRenderInfo>> UpdateGatheredChunks
+    public async Task UpdateGatheredChunks
         (AffectedAreasGatherer chunkGatherer, bool rerenderDelayed)
     {
-        return await Task.Run(() => Render(chunkGatherer, rerenderDelayed)).ConfigureAwait(true);
+        await Task.Run(() => QueueChunksToRender(chunkGatherer, rerenderDelayed)).ConfigureAwait(true);
     }
 
     /// <summary>
     /// Don't call this outside ActionAccumulator
     /// </summary>
-    public List<IRenderInfo> UpdateGatheredChunksSync
+    public void UpdateGatheredChunksSync
         (AffectedAreasGatherer chunkGatherer, bool rerenderDelayed)
     {
-        return Render(chunkGatherer, rerenderDelayed);
+        QueueChunksToRender(chunkGatherer, rerenderDelayed);
+    }
+
+    public void Render(Texture screenSurface, RectI? globalClippingRectangle)
+    {
+        UpdateMainImage(screenSurface, nextRepaint, globalClippingRectangle,
+            null);
+        
+        nextRepaint.Clear();
+    }
+
+    public void Render(Texture screenSurface, ChunkResolution resolution)
+    {
+        VecI chunks = new VecI(
+            (int)Math.Ceiling(doc.SizeBindable.X / (float)resolution.PixelSize()),
+            (int)Math.Ceiling(doc.SizeBindable.Y / (float)resolution.PixelSize()));
+        
+        RectI globalClippingRectangle = new RectI(new VecI(0, 0), doc.SizeBindable);
+        
+        for (int x = 0; x < chunks.X; x++)
+        {
+            for (int y = 0; y < chunks.Y; y++)
+            {
+                RenderChunk(new VecI(x, y), screenSurface, resolution, globalClippingRectangle, null);
+            }
+        }
     }
 
     private Dictionary<ChunkResolution, HashSet<VecI>> FindChunksVisibleOnViewports(bool onDelayed, bool all)
@@ -152,7 +187,7 @@ internal class CanvasUpdater
         }
     }
 
-    private List<IRenderInfo> Render(AffectedAreasGatherer chunkGatherer, bool rerenderDelayed)
+    private void QueueChunksToRender(AffectedAreasGatherer chunkGatherer, bool rerenderDelayed)
     {
         Dictionary<ChunkResolution, HashSet<VecI>> chunksToRerender =
             FindGlobalChunksToRerender(chunkGatherer, rerenderDelayed);
@@ -178,15 +213,14 @@ internal class CanvasUpdater
         }
 
         if (!anythingToUpdate)
-            return new();
+            return;
 
-        List<IRenderInfo> infos = new();
-        UpdateMainImage(chunksToRerender, updatingStoredChunks ? null : chunkGatherer.MainImageArea.GlobalArea.Value,
-            infos);
-        return infos;
+        nextRepaint = chunksToRerender;
     }
 
-    private void UpdateMainImage(Dictionary<ChunkResolution, HashSet<VecI>> chunksToRerender,
+    private void UpdateMainImage(
+        Texture screenSurface,
+        Dictionary<ChunkResolution, HashSet<VecI>> chunksToRerender,
         RectI? globalClippingRectangle, List<IRenderInfo> infos)
     {
         foreach (var (resolution, chunks) in chunksToRerender)
@@ -197,25 +231,20 @@ internal class CanvasUpdater
                 globalScaledClippingRectangle =
                     (RectI?)((RectI)globalClippingRectangle).Scale(resolution.Multiplier()).RoundOutwards();
 
-            Texture screenSurface = doc.Surfaces[resolution];
+            //Texture screenSurface = doc.Surfaces[resolution];
             foreach (var chunkPos in chunks)
             {
-                // TODO render on dedicated thread
-                Dispatcher.UIThread.Post(
-                    () =>
-                    {
-                        RenderChunk(chunkPos, screenSurface, resolution, globalClippingRectangle,
-                            globalScaledClippingRectangle);
-                        RectI chunkRect = new(chunkPos * chunkSize, new(chunkSize, chunkSize));
-                        if (globalScaledClippingRectangle is RectI rect)
-                            chunkRect = chunkRect.Intersect(rect);
-
-                        infos.Add(new DirtyRect_RenderInfo(
-                            chunkRect.Pos,
-                            chunkRect.Size,
-                            resolution
-                        ));
-                    }, DispatcherPriority.Render);
+                RenderChunk(chunkPos, screenSurface, resolution, globalClippingRectangle,
+                    globalScaledClippingRectangle);
+                RectI chunkRect = new(chunkPos * chunkSize, new(chunkSize, chunkSize));
+                if (globalScaledClippingRectangle is RectI rect)
+                    chunkRect = chunkRect.Intersect(rect);
+
+                /*infos.Add(new DirtyRect_RenderInfo(
+                    chunkRect.Pos,
+                    chunkRect.Size,
+                    resolution
+                ));*/
             }
         }
     }
@@ -239,7 +268,7 @@ internal class CanvasUpdater
                     }
 
                     screenSurface.Surface.Canvas.DrawSurface(
-                        chunk.Surface.Surface,
+                        chunk.Surface.DrawingSurface,
                         chunkPos.Multiply(chunk.PixelSize), ReplacingPaint);
                     chunk.Dispose();
 
@@ -251,18 +280,18 @@ internal class CanvasUpdater
                 {
                     if (screenSurface.IsDisposed) return;
 
-                    if (globalScaledClippingRectangle is not null)
+                    /*if (globalScaledClippingRectangle is not null)
                     {
                         screenSurface.Surface.Canvas.Save();
                         screenSurface.Surface.Canvas.ClipRect((RectD)globalScaledClippingRectangle);
-                    }
+                    }*/
 
                     var pos = chunkPos * resolution.PixelSize();
                     screenSurface.Surface.Canvas.DrawRect(pos.X, pos.Y, resolution.PixelSize(),
                         resolution.PixelSize(), ClearPaint);
 
-                    if (globalScaledClippingRectangle is not null)
-                        screenSurface.Surface.Canvas.Restore();
+                    /*if (globalScaledClippingRectangle is not null)
+                        screenSurface.Surface.Canvas.Restore();*/
                 });
     }
 }

+ 6 - 6
src/PixiEditor/Models/Rendering/MemberPreviewUpdater.cs

@@ -560,7 +560,7 @@ internal class MemberPreviewUpdater
         AffectedArea area,
         VecI position, float scaling)
     {
-        PostRender(() =>
+        QueueRender(() =>
         {
             memberVM.PreviewSurface.Surface.Canvas.Save();
             memberVM.PreviewSurface.Surface.Canvas.Scale(scaling);
@@ -608,7 +608,7 @@ internal class MemberPreviewUpdater
     private void RenderLayerMainPreview(IReadOnlyLayerNode layer, Texture surface, AffectedArea area,
         VecI position, float scaling, int frame)
     {
-        PostRender(() =>
+        QueueRender(() =>
         {
             surface.Surface.Canvas.Save();
             surface.Surface.Canvas.Scale(scaling);
@@ -643,7 +643,7 @@ internal class MemberPreviewUpdater
                 new Texture(StructureHelpers.CalculatePreviewSize(internals.Tracker.Document.Size));
         }
 
-        PostRender(() =>
+        QueueRender(() =>
         {
             keyFrameVM.PreviewSurface!.Surface.Canvas.Save();
             float scaling = (float)keyFrameVM.PreviewSurface.Size.X / internals.Tracker.Document.Size.X;
@@ -706,7 +706,7 @@ internal class MemberPreviewUpdater
 
             var member = internals.Tracker.Document.FindMemberOrThrow(guid);
 
-            PostRender(() =>
+            QueueRender(() =>
             {
                 memberVM.MaskPreviewSurface!.Surface.Canvas.Save();
                 memberVM.MaskPreviewSurface.Surface.Canvas.Scale(scaling);
@@ -754,7 +754,7 @@ internal class MemberPreviewUpdater
             float scalingX = (float)nodeVm.ResultPreview.Size.X / node.CachedResult.Size.X;
             float scalingY = (float)nodeVm.ResultPreview.Size.Y / node.CachedResult.Size.Y;
 
-            PostRender(() =>
+            QueueRender(() =>
             {
                 nodeVm.ResultPreview.Surface.Canvas.Save();
                 nodeVm.ResultPreview.Surface.Canvas.Scale(scalingX, scalingY);
@@ -771,7 +771,7 @@ internal class MemberPreviewUpdater
         }
     }
 
-    private void PostRender(Action action)
+    private void QueueRender(Action action)
     {
         if (!DrawingBackendApi.Current.IsHardwareAccelerated)
         {

+ 21 - 16
src/PixiEditor/Styles/Templates/KeyFrame.axaml

@@ -5,24 +5,26 @@
                     xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
                     xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters">
     <ControlTheme TargetType="animations:KeyFrame" x:Key="{x:Type animations:KeyFrame}">
-        <Setter Property="ClipToBounds" Value="False"/>
-        <Setter Property="Height" Value="70"/>
-        <Setter Property="MinWidth" Value="35"/>
+        <Setter Property="ClipToBounds" Value="False" />
+        <Setter Property="Height" Value="70" />
+        <Setter Property="MinWidth" Value="35" />
         <Setter Property="Template">
             <ControlTemplate>
                 <Grid>
                     <Border CornerRadius="{DynamicResource ControlCornerRadius}" Name="MainBorder"
-                            Background="{DynamicResource ThemeBackgroundBrush1}" Margin="0 5"
+                            Background="{DynamicResource ThemeBackgroundBrush1}" Margin="0 15"
                             BorderBrush="{DynamicResource ThemeBorderMidBrush}" BorderThickness="1">
                         <Grid>
-                            <Panel HorizontalAlignment="Right" Name="PART_ResizePanelRight" Width="5" Cursor="SizeWestEast" Background="Transparent" ZIndex="1"/>
+                            <Panel HorizontalAlignment="Right" Name="PART_ResizePanelRight" Width="5"
+                                   Cursor="SizeWestEast" Background="Transparent" ZIndex="1" />
                             <Panel Margin="-35, 0, 0, 0" HorizontalAlignment="Left" Name="PART_ResizePanelLeft"
-                                   Width="5" Cursor="SizeWestEast" Background="Transparent" ZIndex="1"/>
+                                   Width="5" Cursor="SizeWestEast" Background="Transparent" ZIndex="1" />
                         </Grid>
                     </Border>
-                    
+
                     <Border IsVisible="{Binding !IsCollapsed, RelativeSource={RelativeSource TemplatedParent}}"
-                        CornerRadius="{DynamicResource ControlCornerRadius}" Width="60" Height="60" Margin="-30, 0, 0, 0"
+                            CornerRadius="{DynamicResource ControlCornerRadius}" Width="60" Height="60"
+                            Margin="-30, 0, 0, 0"
                             BorderThickness="1" VerticalAlignment="Center" IsHitTestVisible="True" Name="PreviewBorder"
                             HorizontalAlignment="Left" BorderBrush="{DynamicResource ThemeBorderMidBrush}"
                             RenderOptions.BitmapInterpolationMode="None">
@@ -48,24 +50,27 @@
                 </Grid>
             </ControlTemplate>
         </Setter>
-        
+
         <Style Selector="^:collapsed">
-            <Setter Property="Height" Value="30"/>
-            <Setter Property="MinWidth" Value="5"/>
+            <Setter Property="Height" Value="30" />
+            <Setter Property="MinWidth" Value="5" />
         </Style>
-        
+
         <Style Selector="^:collapsed /template/ Panel#PART_ResizePanelLeft">
-            <Setter Property="Margin" Value="0"/>
+            <Setter Property="Margin" Value="0" />
+        </Style>
+        <Style Selector="^:collapsed /template/ Border#MainBorder">
+            <Setter Property="Margin" Value="0 5" />
         </Style>
-        
+
         <Style Selector="^:selected /template/ Border#MainBorder">
             <Setter Property="BorderBrush" Value="{DynamicResource ThemeAccent2Color}" />
         </Style>
-        
+
         <Style Selector="^:selected /template/ Border#PreviewBorder">
             <Setter Property="BorderBrush" Value="{DynamicResource ThemeAccent2Color}" />
         </Style>
-        
+
         <Style Selector="^:disabled">
             <Setter Property="Opacity" Value="0.5" />
         </Style>

+ 2 - 2
src/PixiEditor/ViewModels/Dock/LayoutManager.cs

@@ -63,7 +63,7 @@ internal class LayoutManager
                         Id = "DocumentArea", FallbackContent = new CreateDocumentFallbackView(),
                         Dockables = [ DockContext.CreateDockable(nodeGraphDockViewModel) ]
                     },
-                    FirstSize = 0.85,
+                    SecondSize = 200,
                     SplitDirection = DockingDirection.Bottom,
                     Second = new DockableArea
                     {
@@ -71,7 +71,7 @@ internal class LayoutManager
                         ActiveDockable = DockContext.CreateDockable(timelineDockViewModel)
                     }
                 },
-                FirstSize = 0.85,
+                SecondSize = 360,
                 SplitDirection = DockingDirection.Right,
                 Second = new DockableTree
                 {

+ 17 - 3
src/PixiEditor/ViewModels/Document/DocumentViewModel.cs

@@ -827,18 +827,26 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         }
     }
 
-    public Image[] RenderFrames(Func<Surface, Surface> processFrameAction = null)
+    public Image[] RenderFrames(Func<Surface, Surface> processFrameAction = null, CancellationToken token = default)
     {
         if (AnimationDataViewModel.KeyFrames.Count == 0)
             return [];
+        
+        if(token.IsCancellationRequested)
+            return [];
 
         int firstFrame = AnimationDataViewModel.FirstFrame;
         int framesCount = AnimationDataViewModel.FramesCount;
         int lastFrame = firstFrame + framesCount;
 
         Image[] images = new Image[framesCount];
+        
+        // TODO: Multi-threading
         for (int i = firstFrame; i < lastFrame; i++)
         {
+            if (token.IsCancellationRequested)
+                return [];
+            
             double normalizedTime = (double)(i - firstFrame) / framesCount;
             KeyFrameTime frameTime = new KeyFrameTime(i, normalizedTime);
             var surface = TryRenderWholeImage(frameTime);
@@ -863,7 +871,8 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
     ///     Render frames progressively and disposes the surface after processing.
     /// </summary>
     /// <param name="processFrameAction">Action to perform on rendered frame</param>
-    public void RenderFramesProgressive(Action<Surface, int> processFrameAction)
+    /// <param name="token"></param>
+    public void RenderFramesProgressive(Action<Surface, int> processFrameAction, CancellationToken token)
     {
         if (AnimationDataViewModel.KeyFrames.Count == 0)
             return;
@@ -876,7 +885,12 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
 
         for (int i = firstFrame; i < lastFrame; i++)
         {
-            var surface = TryRenderWholeImage(i);
+            if (token.IsCancellationRequested)
+                return;
+            
+            KeyFrameTime frameTime = new KeyFrameTime(i, (double)(i - firstFrame) / framesCount);
+            
+            var surface = TryRenderWholeImage(frameTime);
             if (surface.IsT0)
             {
                 continue;

+ 21 - 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,23 @@ 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 (result.result == SaveResult.Success)
+                        IOperatingSystem.Current.OpenFolder(result.finalPath);
+                    else
+                        ShowSaveError((DialogSaveResult)result.result);
+                });
+                
+                await dialog.ShowDialog();
             }
         }
         catch (RecoverableException e)

+ 62 - 24
src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml.cs

@@ -192,7 +192,7 @@ internal partial class ExportFilePopup : PixiEditorPopup
         videoPreviewTimer.Stop();
         videoPreviewTimer.Tick -= OnVideoPreviewTimerOnTick;
         videoPreviewTimer = null;
-        cancellationTokenSource.Dispose();
+        cancellationTokenSource.Cancel();
 
         ExportPreview?.Dispose();
 
@@ -210,6 +210,11 @@ internal partial class ExportFilePopup : PixiEditorPopup
         if (videoPreviewFrames.Length > 0)
         {
             ExportPreview.DrawingSurface.Canvas.Clear();
+            if (videoPreviewFrames[activeFrame] == null)
+            {
+                return;
+            }
+            
             ExportPreview.DrawingSurface.Canvas.DrawImage(videoPreviewFrames[activeFrame], 0, 0);
             activeFrame = (activeFrame + 1) % videoPreviewFrames.Length;
         }
@@ -232,7 +237,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 +248,72 @@ internal partial class ExportFilePopup : PixiEditorPopup
 
     private void RenderImagePreview()
     {
-        if (IsSpriteSheetExport)
-        {
-            GenerateSpriteSheetPreview();
-        }
-        else
+        try
         {
-            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();
+                try
+                {
+                    document.RenderFramesProgressive(
+                        (frame, index) =>
+                        {
+                            int x = index % clampedColumns;
+                            int y = index / clampedColumns;
+                            var resized = frame.ResizeNearestNeighbor(new VecI(singleFrameSize.X, singleFrameSize.Y));
+                            Dispatcher.UIThread.Post(() =>
+                            {
+                                if (ExportPreview.IsDisposed) return;
+                                ExportPreview!.DrawingSurface.Canvas.DrawSurface(resized.DrawingSurface,
+                                    x * singleFrameSize.X,
+                                    y * singleFrameSize.Y);
+                                resized.Dispose();
+                            });
+                        }, cancellationTokenSource.Token);
+                }
+                catch 
+                {
+                    // Ignore
+                }
             });
         }
     }
@@ -295,7 +330,7 @@ internal partial class ExportFilePopup : PixiEditorPopup
         Task.Run(
             () =>
             {
-                videoPreviewFrames = document.RenderFrames(ProcessFrame);
+                videoPreviewFrames = document.RenderFrames(ProcessFrame, cancellationTokenSource.Token);
             }, cancellationTokenSource.Token).ContinueWith(_ =>
         {
             Dispatcher.UIThread.Invoke(() =>
@@ -429,7 +464,10 @@ internal partial class ExportFilePopup : PixiEditorPopup
     {
         if (e.Sender is ExportFilePopup popup)
         {
-            popup.RenderPreview();
+            if (popup.videoPreviewTimer != null)
+            {
+                popup.RenderPreview();
+            }
         }
     }
 }

+ 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();
+    }
+}
+

+ 3 - 1
src/PixiEditor/Views/Main/Tools/ToolPickerButton.axaml

@@ -7,6 +7,7 @@
              xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
              xmlns:tools="clr-namespace:PixiEditor.ViewModels.Tools"
              xmlns:markupExtensions="clr-namespace:PixiEditor.Helpers.MarkupExtensions"
+             xmlns:ui1="clr-namespace:PixiEditor.Helpers.UI"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              d:DataContext="{tools:ToolViewModel}"
              x:Class="PixiEditor.Views.Main.Tools.ToolPickerButton">
@@ -20,7 +21,8 @@
             Background="{DynamicResource ThemeBackgroundBrush1}">
         <Button.Template>
             <ControlTemplate>
-                <Border Height="40" Width="40" Background="{DynamicResource ThemeBackgroundBrush1}">
+                <Border Height="40" Width="40" 
+                        Background="{DynamicResource ThemeBackgroundBrush1}">
                     <ContentPresenter Content="{TemplateBinding Content}"/>
                 </Border>
             </ControlTemplate>

+ 2 - 2
src/PixiEditor/Views/Nodes/NodeView.cs

@@ -34,7 +34,7 @@ public class NodeView : TemplatedControl
         AvaloniaProperty.Register<NodeView, ObservableRangeCollection<INodePropertyHandler>>(
             nameof(Outputs));
 
-    public static readonly StyledProperty<Surface> ResultPreviewProperty = AvaloniaProperty.Register<NodeView, Surface>(
+    public static readonly StyledProperty<Texture> ResultPreviewProperty = AvaloniaProperty.Register<NodeView, Texture>(
         nameof(ResultPreview));
 
     public static readonly StyledProperty<bool> IsSelectedProperty = AvaloniaProperty.Register<NodeView, bool>(
@@ -64,7 +64,7 @@ public class NodeView : TemplatedControl
         set => SetValue(IsSelectedProperty, value);
     }
 
-    public Surface ResultPreview
+    public Texture ResultPreview
     {
         get => GetValue(ResultPreviewProperty);
         set => SetValue(ResultPreviewProperty, value);

+ 30 - 11
src/PixiEditor/Views/Rendering/Scene.cs

@@ -15,6 +15,7 @@ using ChunkyImageLib.DataHolders;
 using PixiEditor.DrawingApi.Core;
 using PixiEditor.DrawingApi.Core.Bridge;
 using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surfaces;
 using PixiEditor.DrawingApi.Skia;
 using PixiEditor.DrawingApi.Skia.Extensions;
 using PixiEditor.Extensions.UI.Overlays;
@@ -107,6 +108,8 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
     private List<Overlay> mouseOverOverlays = new();
 
     private double sceneOpacity = 1;
+    
+    private static Scene instance;
 
     static Scene()
     {
@@ -136,9 +139,16 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
         };
     }
 
+    public static event Action<Texture> Paint;
+
+    public static void RequestRender()
+    {
+        instance.QueueRender();
+    }
+
     public override void Render(DrawingContext context)
     {
-        if (Surface == null || Surface.IsDisposed || Document == null) return;
+        //if (Surface == null || Document == null) return;
 
         float angle = (float)MathUtil.RadiansToDegrees(AngleRadians);
 
@@ -147,8 +157,8 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
         RectD dirtyBounds = new RectD(0, 0, Document.Width / resolutionScale, Document.Height / resolutionScale);
         Rect dirtyRect = new Rect(0, 0, Document.Width / resolutionScale, Document.Height / resolutionScale);
 
-        Surface.Surface.Flush();
-        using var operation = new DrawSceneOperation(Surface, Document, CanvasPos, Scale * resolutionScale, angle,
+        //Surface.Surface.Flush();
+        using var operation = new DrawSceneOperation(Paint, Document, CanvasPos, Scale * resolutionScale, angle,
             FlipX, FlipY,
             dirtyRect,
             Bounds,
@@ -490,15 +500,18 @@ internal class DrawSceneOperation : SkiaDrawOperation
 
     public RectI SurfaceRectToRender { get; }
 
+    public event Action<Texture> Paint;
+
     private SKPaint _paint = new SKPaint();
 
     private bool hardwareAccelerationAvailable = DrawingBackendApi.Current.IsHardwareAccelerated;
 
-    public DrawSceneOperation(Texture surface, DocumentViewModel document, VecD contentPosition, double scale,
+    public DrawSceneOperation(Action<Texture> paint, DocumentViewModel document, VecD contentPosition, double scale,
         double angle, bool flipX, bool flipY, Rect dirtyBounds, Rect viewportBounds, double opacity,
         ColorMatrix colorMatrix) : base(dirtyBounds)
     {
-        Surface = surface;
+        //Surface = surface;
+        Paint += paint;
         Document = document;
         ContentPosition = contentPosition;
         Scale = scale;
@@ -508,29 +521,35 @@ internal class DrawSceneOperation : SkiaDrawOperation
         ColorMatrix = colorMatrix;
         ViewportBounds = viewportBounds;
         _paint.Color = _paint.Color.WithAlpha((byte)(opacity * 255));
-        SurfaceRectToRender = FindRectToRender((float)scale);
+        //SurfaceRectToRender = FindRectToRender((float)scale);
+        SurfaceRectToRender = new RectI(VecI.Zero, Document.SizeBindable);
     }
 
     public override void Render(ISkiaSharpApiLease lease)
     {
-        if (Surface == null || Surface.IsDisposed || Document == null) return;
+        //if (Surface == null || Surface.IsDisposed || Document == null) return;
 
         SKCanvas canvas = lease.SkCanvas;
 
         canvas.Save();
 
-        if (SurfaceRectToRender.IsZeroOrNegativeArea)
+        /*if (SurfaceRectToRender.IsZeroOrNegativeArea)
         {
             canvas.Restore();
             return;
-        }
+        }*/
 
         using var ctx = DrawingBackendApi.Current.RenderOnDifferentGrContext(lease.GrContext);
 
         var matrixValues = new float[ColorMatrix.Width * ColorMatrix.Height];
         ColorMatrix.TryGetMembers(matrixValues);
 
-        _paint.ColorFilter = SKColorFilter.CreateColorMatrix(matrixValues);
+        DrawingSurface drawingSurface = DrawingSurface.CreateFromNative(lease.SkSurface);
+        Texture texture = Texture.FromExisting(drawingSurface);
+        
+        Paint?.Invoke(texture);
+        
+        /*_paint.ColorFilter = SKColorFilter.CreateColorMatrix(matrixValues);
 
         if (!hardwareAccelerationAvailable)
         {
@@ -542,7 +561,7 @@ internal class DrawSceneOperation : SkiaDrawOperation
         else
         {
             canvas.DrawSurface(Surface.Surface.Native as SKSurface, 0, 0, _paint);
-        }
+        }*/
 
         canvas.Restore();
     }

+ 10 - 11
src/PixiEditor/Views/Visuals/TextureControl.cs

@@ -21,9 +21,8 @@ public class TextureControl : Control
     public static readonly StyledProperty<Stretch> StretchProperty = AvaloniaProperty.Register<TextureControl, Stretch>(
         nameof(Stretch), Stretch.Uniform);
 
-    public static readonly StyledProperty<IBrush> BackgroundProperty =
-        AvaloniaProperty.Register<TextureControl, IBrush>(
-            nameof(Background));
+    public static readonly StyledProperty<IBrush> BackgroundProperty = AvaloniaProperty.Register<TextureControl, IBrush>
+    (nameof(Background));
 
     public Stretch Stretch
     {
@@ -99,14 +98,14 @@ public class TextureControl : Control
     {
         if (Background != null)
         {
-            context.FillRectangle(Background, new Rect(0, 0, Bounds.Width, Bounds.Height));
+            context.FillRectangle(Background, new Rect(Bounds.Size));
         }
-
-        if (Texture == null)
+        
+        if (Texture == null || Texture.IsDisposed)
         {
             return;
         }
-
+        
         Texture texture = Texture;
         texture.Surface.Flush();
         ICustomDrawOperation drawOperation = new DrawTextureOperation(
@@ -116,20 +115,20 @@ public class TextureControl : Control
 
         context.Custom(drawOperation);
     }
-
+    
     private void OnTextureChanged(AvaloniaPropertyChangedEventArgs<Texture> args)
     {
         if (args.OldValue.Value != null)
         {
             args.OldValue.Value.Changed -= TextureOnChanged;
         }
-
+        
         if (args.NewValue.Value != null)
         {
             args.NewValue.Value.Changed += TextureOnChanged;
         }
     }
-
+    
     private void TextureOnChanged(RectD? changedRect)
     {
         Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
@@ -158,7 +157,7 @@ internal class DrawTextureOperation : SkiaDrawOperation
         {
             return;
         }
-
+        
         SKCanvas canvas = lease.SkCanvas;
 
         using var ctx = DrawingBackendApi.Current.RenderOnDifferentGrContext(lease.GrContext);

+ 1 - 0
src/PixiEditor/Views/Visuals/TextureImage.cs

@@ -20,6 +20,7 @@ public class TextureImage : IImage
 
     public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
     {
+        Texture.Surface.Flush();
         context.Custom(new DrawTextureOperation(destRect, Stretch, Texture));
     }
 }