Browse Source

Added export custom render output

Krzysztof Krysiński 3 months ago
parent
commit
75783ed30c

+ 2 - 2
src/PixiEditor.ChangeableDocument/Changeables/Graph/InputProperty.cs

@@ -37,11 +37,12 @@ public class InputProperty : IInputProperty
                 return null;
             }
 
+            object target = connectionValue;
             if (!ValueType.IsAssignableTo(typeof(Delegate)) && connectionValue is Delegate connectionField)
             {
                 try
                 {
-                    return connectionField.DynamicInvoke(FuncContext.NoContext);
+                    target = connectionField.DynamicInvoke(FuncContext.NoContext);
                 }
                 catch
                 {
@@ -64,7 +65,6 @@ public class InputProperty : IInputProperty
                 return FuncFactoryDelegate(func);
             }
 
-            object target = connectionValue;
             if (target is ShaderExpressionVariable shaderExpression)
             {
                 target = shaderExpression.GetConstant();

+ 8 - 2
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Workspace/CustomOutputNode.cs

@@ -9,9 +9,13 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Workspace;
 public class CustomOutputNode : Node, IRenderInput, IPreviewRenderable
 {
     public const string OutputNamePropertyName = "OutputName";
-    public RenderInputProperty Input { get; } 
+    public const string IsDefaultExportPropertyName = "IsDefaultExport";
+    public const string ExportSizePropertyName = "ExportSize";
+    public RenderInputProperty Input { get; }
     public InputProperty<string> OutputName { get; }
-    
+    public InputProperty<bool> IsDefaultExport { get; }
+    public InputProperty<VecI> ExportSize { get; }
+
     private VecI? lastDocumentSize;
     public CustomOutputNode()
     {
@@ -19,6 +23,8 @@ public class CustomOutputNode : Node, IRenderInput, IPreviewRenderable
         AddInputProperty(Input);
         
         OutputName = CreateInput(OutputNamePropertyName, "OUTPUT_NAME", "");
+        IsDefaultExport = CreateInput(IsDefaultExportPropertyName, "IS_DEFAULT_EXPORT", false);
+        ExportSize = CreateInput(ExportSizePropertyName, "EXPORT_SIZE", VecI.Zero);
     }
 
     public override Node CreateCopy()

+ 0 - 35
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Workspace/ExportZoneNode.cs

@@ -1,35 +0,0 @@
-using Drawie.Numerics;
-using PixiEditor.ChangeableDocument.Rendering;
-
-namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Workspace;
-
-[NodeInfo("ExportZone")]
-public class ExportZoneNode : Node
-{
-    public const string IsDefaultName = "IsDefault";
-    public const string SizeName = "Size";
-    public const string OffsetName = "Offset";
-    public InputProperty<string> Name { get; }
-    public InputProperty<VecI> Offset { get; }
-    public InputProperty<VecI> Size { get; }
-    public InputProperty<bool> IsDefault { get; }
-
-    public ExportZoneNode()
-    {
-        Name = CreateInput<string>("Name", "NAME", string.Empty);
-        Offset = CreateInput<VecI>(OffsetName, "POSITION", VecI.Zero);
-        Size = CreateInput<VecI>(SizeName, "SIZE", VecI.One);
-        IsDefault = CreateInput<bool>(IsDefaultName, "IS_DEFAULT", false);
-    }
-
-    protected override void OnExecute(RenderContext context)
-    {
-        // This node is used to define the export zone, so it doesn't need to do anything in the execution phase.
-        // The export zone is defined by the position and size inputs.
-    }
-
-    public override Node CreateCopy()
-    {
-        return new ExportZoneNode();
-    }
-}

+ 49 - 0
src/PixiEditor.ChangeableDocument/Changes/NodeGraph/EvaluateGraph_Change.cs

@@ -0,0 +1,49 @@
+using Drawie.Backend.Core;
+using PixiEditor.ChangeableDocument.Changeables.Animations;
+using PixiEditor.ChangeableDocument.Changeables.Graph;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changes.NodeGraph;
+
+internal class EvaluateGraph_Change : Change
+{
+    private readonly Guid endNodeGuid;
+    private readonly KeyFrameTime frameTime;
+
+    [GenerateMakeChangeAction]
+    public EvaluateGraph_Change(Guid endNodeGuid, KeyFrameTime frameTime)
+    {
+        this.endNodeGuid = endNodeGuid;
+        this.frameTime = frameTime;
+    }
+
+    public override bool InitializeAndValidate(Document target)
+    {
+        return target.HasNode(endNodeGuid);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply,
+        out bool ignoreInUndo)
+    {
+        ignoreInUndo = true;
+
+        var node = target.FindNode(endNodeGuid);
+        var queue = GraphUtils.CalculateExecutionQueue(node);
+
+        using Texture renderTexture = Texture.ForProcessing(target.Size, target.ProcessingColorSpace);
+        RenderContext context =
+            new(renderTexture.DrawingSurface, frameTime, ChunkResolution.Full, target.Size,
+                target.ProcessingColorSpace) { FullRerender = true };
+        foreach (var nodeToEvaluate in queue)
+        {
+            nodeToEvaluate.Execute(context);
+        }
+
+        return new None();
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        return new None();
+    }
+}

+ 1 - 1
src/PixiEditor.ChangeableDocument/Rendering/RenderingUtils.cs

@@ -10,7 +10,7 @@ public static class RenderingUtils
 {
     public static IReadOnlyNodeGraph SolveFinalNodeGraph(string? targetOutput, IReadOnlyDocument document)
     {
-        if (targetOutput == null || targetOutput == "DEFAULT")
+        if (targetOutput is null or "DEFAULT")
         {
             return document.NodeGraph;
         }

+ 5 - 3
src/PixiEditor/Data/Localization/Languages/en.json

@@ -812,8 +812,8 @@
   "DUPLICATE_CEL": "Duplicate cel",
   "DUPLICATE_CEL_DESCRIPTIVE": "Duplicate cel in the current frame",
   "RENDER_PREVIEW": "Render preview",
-  "OUTPUT_NAME": "Preview name",
-  "CUSTOM_OUTPUT_NODE": "Preview Node",
+  "OUTPUT_NAME": "Output name",
+  "CUSTOM_OUTPUT_NODE": "Custom Output",
   "TOGGLE_HUD": "Toggle HUD",
   "OPEN_TIMELINE": "Open timeline",
   "OPEN_NODE_GRAPH": "Open node graph",
@@ -1040,5 +1040,7 @@
   "COLOR_MATRIX_FILTER_NODE": "Color Matrix Filter",
   "WORKSPACE": "Workspace",
   "EXPORT_ZONE_NODE": "Export Zone",
-  "IS_DEFAULT": "Is default"
+  "IS_DEFAULT_EXPORT": "Is Default Export",
+  "EXPORT_OUTPUT": "Export Output",
+  "EXPORT_SIZE": "Export Size"
 }

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

@@ -40,7 +40,7 @@ internal abstract class ImageFileType : IoFileType
                 return new SaveResult(SaveResultType.CustomError, "ERR_EXPORT_SIZE_INVALID");
             }
 
-            var maybeBitmap = document.TryRenderWholeImage(0, exportSize);
+            var maybeBitmap = document.TryRenderWholeImage(0, exportSize, exportConfig.ExportOutput);
             if (maybeBitmap.IsT0)
                 return new SaveResult(SaveResultType.ConcurrencyError);
 
@@ -84,7 +84,7 @@ internal abstract class ImageFileType : IoFileType
                 return new SaveResult(SaveResultType.CustomError, "ERR_EXPORT_SIZE_INVALID");
             }
 
-            var maybeBitmap = document.TryRenderWholeImage(0, exportSize);
+            var maybeBitmap = document.TryRenderWholeImage(0, exportSize, config.ExportOutput);
             if (maybeBitmap.IsT0)
                 return new SaveResult(SaveResultType.ConcurrencyError);
 
@@ -140,7 +140,7 @@ internal abstract class ImageFileType : IoFileType
                 surface!.DrawingSurface.Canvas.DrawSurface(target.DrawingSurface, x * config.ExportSize.X,
                     y * config.ExportSize.Y);
                 target.Dispose();
-            }, job?.CancellationTokenSource.Token ?? CancellationToken.None);
+            }, job?.CancellationTokenSource.Token ?? CancellationToken.None, config.ExportOutput);
 
         return surface;
     }

+ 1 - 0
src/PixiEditor/Models/IO/ExportConfig.cs

@@ -13,6 +13,7 @@ public class ExportConfig
    public IAnimationRenderer? AnimationRenderer { get; set; }
    
    public VectorExportConfig? VectorExportConfig { get; set; }
+   public string ExportOutput { get; set; }
 
    public ExportConfig(VecI exportSize)
    {

+ 160 - 42
src/PixiEditor/ViewModels/Document/DocumentViewModel.cs

@@ -1,31 +1,15 @@
-using System.Collections;
-using System.Collections.Generic;
-using System.Collections.Immutable;
+using System.Collections.Immutable;
 using System.Collections.ObjectModel;
-using System.IO;
-using System.Linq;
-using System.Runtime.CompilerServices;
-using System.Text.Json;
-using Avalonia;
-using Avalonia.Media.Imaging;
 using Avalonia.Threading;
-using ChunkyImageLib;
 using ChunkyImageLib.DataHolders;
-using ChunkyImageLib.Operations;
-using Microsoft.CodeAnalysis.CSharp.Syntax;
 using Microsoft.Extensions.DependencyInjection;
 using PixiEditor.Models.DocumentPassthroughActions;
-using PixiEditor.Models.Position;
-using PixiEditor.ViewModels.SubViewModels;
-using PixiEditor.ChangeableDocument.Actions;
 using PixiEditor.ChangeableDocument.Actions.Generated;
 using PixiEditor.ChangeableDocument.Actions.Undo;
 using PixiEditor.ChangeableDocument.Changeables.Animations;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
-using PixiEditor.ChangeableDocument.ChangeInfos;
-using PixiEditor.ChangeableDocument.Changes.NodeGraph;
 using PixiEditor.ChangeableDocument.Enums;
 using PixiEditor.ChangeableDocument.Rendering;
 using Drawie.Backend.Core;
@@ -44,7 +28,6 @@ using PixiEditor.Models.DocumentModels;
 using PixiEditor.Models.DocumentModels.Public;
 using PixiEditor.Models.DocumentModels.UpdateableChangeExecutors.Features;
 using PixiEditor.Models.Handlers;
-using PixiEditor.Models.Layers;
 using PixiEditor.Models.Rendering;
 using PixiEditor.Models.Serialization;
 using PixiEditor.Models.Serialization.Factories;
@@ -52,19 +35,15 @@ using PixiEditor.Models.Structures;
 using PixiEditor.Models.Tools;
 using Drawie.Numerics;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Workspace;
-using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
 using PixiEditor.Models.IO;
 using PixiEditor.Parser;
 using PixiEditor.Parser.Skia;
-using PixiEditor.ViewModels.Document.Nodes;
 using PixiEditor.ViewModels.Document.Nodes.Workspace;
 using PixiEditor.ViewModels.Document.TransformOverlays;
 using PixiEditor.Views.Overlays.SymmetryOverlay;
 using BlendMode = Drawie.Backend.Core.Surfaces.BlendMode;
 using Color = Drawie.Backend.Core.ColorsImpl.Color;
 using Colors = Drawie.Backend.Core.ColorsImpl.Colors;
-using Node = PixiEditor.Parser.Graph.Node;
-using Point = Avalonia.Point;
 
 namespace PixiEditor.ViewModels.Document;
 
@@ -602,34 +581,126 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         }
     }
 
-    public RectI GetDefaultRenderZone()
+
+    public (string name, VecI originalSize)[] GetAvailableExportOutputs()
     {
-        // TODO: This should be a part of the ChangeableDocument
+        var allExportNodes = NodeGraph.AllNodes.Where(x => x is CustomOutputNodeViewModel).ToArray();
+
+        if (allExportNodes.Length == 0)
+        {
+            return [("DEFAULT", SizeBindable)];
+        }
+
+        using var block = Operations.StartChangeBlock();
+        foreach (var node in allExportNodes)
+        {
+            if (node is not CustomOutputNodeViewModel)
+                continue;
+
+            Internals.ActionAccumulator.AddActions(new EvaluateGraph_Action(node.Id,
+                AnimationDataViewModel.ActiveFrameTime));
+
+            Internals.ActionAccumulator.AddActions(
+                new GetComputedPropertyValue_Action(node.Id, CustomOutputNode.OutputNamePropertyName, true),
+                new GetComputedPropertyValue_Action(node.Id, CustomOutputNode.ExportSizePropertyName, true));
+        }
+
+        block.ExecuteQueuedActions();
+
+        var exportNodes = NodeGraph.AllNodes.Where(x => x is CustomOutputNodeViewModel).ToArray();
+        var exportNames = new List<(string name, VecI origianlSize)>();
+        exportNames.Add(("DEFAULT", SizeBindable));
+
+        foreach (var node in exportNodes)
+        {
+            if (node is not CustomOutputNodeViewModel exportZone)
+                continue;
+
+            var name = exportZone.Inputs.FirstOrDefault(x => x.PropertyName == CustomOutputNode.OutputNamePropertyName);
+
+
+            if (name?.ComputedValue is not string finalName)
+                continue;
+
+            if (string.IsNullOrEmpty(finalName))
+            {
+                continue;
+            }
+
+            VecI originalSize =
+                exportZone.Inputs.FirstOrDefault(x => x.PropertyName == CustomOutputNode.ExportSizePropertyName)
+                    ?.ComputedValue as VecI? ?? SizeBindable;
+            if (originalSize.ShortestAxis <= 0)
+            {
+                originalSize = SizeBindable;
+            }
+
+            exportNames.Add((finalName, originalSize));
+        }
+
+        return exportNames.ToArray();
+    }
+
+    public VecI GetDefaultRenderSize(out string? renderOutputName)
+    {
+        var allExportNodes = NodeGraph.AllNodes.Where(x => x is CustomOutputNodeViewModel).ToArray();
+
+        renderOutputName = "DEFAULT";
+        if (allExportNodes.Length == 0)
+        {
+            return SizeBindable;
+        }
 
-        var exportNodes = NodeGraph.AllNodes.Where(
-            x => x is ExportZoneNodeViewModel exportZone
-                 && exportZone.Inputs.Any(x => x is { PropertyName: ExportZoneNode.IsDefaultName, Value: true })).ToArray();
+        using var block = Operations.StartChangeBlock();
+        foreach (var node in allExportNodes)
+        {
+            if (node is not CustomOutputNodeViewModel exportZone)
+                continue;
+
+            Internals.ActionAccumulator.AddActions(new EvaluateGraph_Action(node.Id,
+                AnimationDataViewModel.ActiveFrameTime));
+
+            Internals.ActionAccumulator.AddActions(
+                new GetComputedPropertyValue_Action(node.Id, CustomOutputNode.OutputNamePropertyName, true),
+                new GetComputedPropertyValue_Action(node.Id, CustomOutputNode.IsDefaultExportPropertyName, true),
+                new GetComputedPropertyValue_Action(node.Id, CustomOutputNode.ExportSizePropertyName, true));
+        }
+
+        block.ExecuteQueuedActions();
+
+        var exportNodes = NodeGraph.AllNodes.Where(x => x is CustomOutputNodeViewModel exportZone
+                                                        && exportZone.Inputs.Any(x => x is
+                                                        {
+                                                            PropertyName: CustomOutputNode.IsDefaultExportPropertyName,
+                                                            ComputedValue: true
+                                                        })).ToArray();
 
         if (exportNodes.Length == 0)
-            return new RectI(VecI.Zero, SizeBindable);
+            return SizeBindable;
 
         var exportNode = exportNodes.FirstOrDefault();
 
         if (exportNode is null)
-            return new RectI(VecI.Zero, SizeBindable);
+            return SizeBindable;
 
-        var exportSize = exportNode.Inputs.FirstOrDefault(x => x.PropertyName == ExportZoneNode.SizeName);
+        var exportSize =
+            exportNode.Inputs.FirstOrDefault(x => x.PropertyName == CustomOutputNode.ExportSizePropertyName);
 
         if (exportSize is null)
-            return new RectI(VecI.Zero, SizeBindable);
+            return SizeBindable;
 
-        if (exportSize.Value is VecI finalSize)
+        if (exportSize.ComputedValue is VecI finalSize)
         {
-            VecI offset = exportNode.Inputs.FirstOrDefault(x => x.PropertyName == ExportZoneNode.OffsetName)?.ComputedValue as VecI? ?? VecI.Zero;
-            return new RectI(offset, finalSize);
+            if (exportNode.Inputs.FirstOrDefault(x => x.PropertyName == CustomOutputNode.OutputNamePropertyName) is
+                { } name)
+            {
+                renderOutputName = name.ComputedValue?.ToString();
+            }
+
+            return finalSize;
         }
 
-        return new RectI(VecI.Zero, SizeBindable);
+        return SizeBindable;
     }
 
     public ICrossDocumentPipe<T> ShareNode<T>(Guid layerId) where T : class, IReadOnlyNode
@@ -639,6 +710,51 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
 
     public OneOf<Error, Surface> TryRenderWholeImage(KeyFrameTime frameTime, VecI renderSize)
     {
+        return TryRenderWholeImage(frameTime, renderSize, SizeBindable);
+    }
+
+    public OneOf<Error, Surface> TryRenderWholeImage(KeyFrameTime frameTime, string? renderOutputName)
+    {
+        (string name, VecI originalSize)[] outputs = [];
+
+        Dispatcher.UIThread.Invoke(() =>
+        {
+            outputs = GetAvailableExportOutputs();
+        });
+
+        string outputName = renderOutputName ?? "DEFAULT";
+        var output = outputs.FirstOrDefault(x => x.name == outputName);
+        VecI originalSize = string.IsNullOrEmpty(output.name) ? SizeBindable : output.originalSize;
+        if (originalSize.ShortestAxis <= 0)
+            return new Error();
+
+        return TryRenderWholeImage(frameTime, originalSize, originalSize, renderOutputName);
+    }
+
+    public OneOf<Error, Surface> TryRenderWholeImage(KeyFrameTime frameTime, VecI renderSize, string? renderOutputName)
+    {
+        (string name, VecI originalSize)[] outputs = [];
+
+        Dispatcher.UIThread.Invoke(() =>
+        {
+            outputs = GetAvailableExportOutputs();
+        });
+
+        string outputName = renderOutputName ?? "DEFAULT";
+        var output = outputs.FirstOrDefault(x => x.name == outputName);
+        VecI originalSize = string.IsNullOrEmpty(output.name) ? SizeBindable : output.originalSize;
+        if (originalSize.ShortestAxis <= 0)
+            return new Error();
+
+        return TryRenderWholeImage(frameTime, renderSize, originalSize, renderOutputName);
+    }
+
+    public OneOf<Error, Surface> TryRenderWholeImage(KeyFrameTime frameTime, VecI renderSize, VecI originalSize,
+        string? renderOutputName = null)
+    {
+        if (renderSize.ShortestAxis <= 0)
+            return new Error();
+
         try
         {
             Surface finalSurface = null;
@@ -646,10 +762,10 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
             {
                 finalSurface = Surface.ForDisplay(renderSize);
                 finalSurface.DrawingSurface.Canvas.Save();
-                VecD scaling = new VecD(renderSize.X / (double)SizeBindable.X, renderSize.Y / (double)SizeBindable.Y);
+                VecD scaling = new VecD(renderSize.X / (double)originalSize.X, renderSize.Y / (double)originalSize.Y);
 
                 finalSurface.DrawingSurface.Canvas.Scale((float)scaling.X, (float)scaling.Y);
-                Renderer.RenderDocument(finalSurface.DrawingSurface, frameTime, renderSize);
+                Renderer.RenderDocument(finalSurface.DrawingSurface, frameTime, renderSize, renderOutputName);
 
                 finalSurface.DrawingSurface.Canvas.Restore();
             });
@@ -1066,7 +1182,8 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         }
     }
 
-    public Image[] RenderFrames(Func<Surface, Surface> processFrameAction = null, CancellationToken token = default)
+    public Image[] RenderFrames(Func<Surface, Surface> processFrameAction = null, string? renderOutput = null,
+        CancellationToken token = default)
     {
         if (token.IsCancellationRequested)
             return [];
@@ -1086,7 +1203,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
 
             double normalizedTime = (double)(i - firstFrame) / framesCount;
             KeyFrameTime frameTime = new KeyFrameTime(i, normalizedTime);
-            var surface = TryRenderWholeImage(frameTime);
+            var surface = TryRenderWholeImage(frameTime, renderOutput);
             if (surface.IsT0)
             {
                 continue;
@@ -1108,8 +1225,9 @@ 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>
-    /// <param name="token"></param>
-    public void RenderFramesProgressive(Action<Surface, int> processFrameAction, CancellationToken token)
+    /// <param name="token">Cancellation token to cancel the rendering</param>
+    public void RenderFramesProgressive(Action<Surface, int> processFrameAction, CancellationToken token,
+        string? renderOutput)
     {
         int firstFrame = AnimationDataViewModel.GetFirstVisibleFrame();
         int framesCount = AnimationDataViewModel.GetLastVisibleFrame();
@@ -1124,7 +1242,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
 
             KeyFrameTime frameTime = new KeyFrameTime(i, (double)(i - firstFrame) / framesCount);
 
-            var surface = TryRenderWholeImage(frameTime);
+            var surface = TryRenderWholeImage(frameTime, renderOutput);
             if (surface.IsT0)
             {
                 continue;

+ 0 - 11
src/PixiEditor/ViewModels/Document/Nodes/Workspace/ExportZoneNodeViewModel.cs

@@ -1,11 +0,0 @@
-using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Workspace;
-using PixiEditor.UI.Common.Fonts;
-using PixiEditor.ViewModels.Nodes;
-
-namespace PixiEditor.ViewModels.Document.Nodes.Workspace;
-
-[NodeViewModel("EXPORT_ZONE_NODE", "WORKSPACE", PixiPerfectIcons.CanvasResize)]
-internal class ExportZoneNodeViewModel : NodeViewModel<ExportZoneNode>
-{
-
-}

+ 38 - 15
src/PixiEditor/Views/Dialogs/ExportFileDialog.cs

@@ -1,4 +1,5 @@
-using System.Threading.Tasks;
+using System.Collections.ObjectModel;
+using System.Threading.Tasks;
 using Avalonia.Controls;
 using PixiEditor.AnimationRenderer.FFmpeg;
 using Drawie.Backend.Core.Numerics;
@@ -20,10 +21,11 @@ internal class ExportFileDialog : CustomDialog
 
     private int fileWidth;
 
-    private int offsetX;
-    private int offsetY;
+    private RenderOutputConfig exportOutput;
 
     private string suggestedName;
+
+    private ObservableCollection<RenderOutputConfig> availableExportOutputs = new ObservableCollection<RenderOutputConfig>();
     
     private DocumentViewModel document;
     
@@ -31,9 +33,11 @@ internal class ExportFileDialog : CustomDialog
 
     public ExportFileDialog(Window owner, DocumentViewModel doc) : base(owner)
     {
-        RectI zone = doc.GetDefaultRenderZone();
-        FileWidth = zone.Width;
-        FileHeight = zone.Height;
+        AvailableExportOutputs = new ObservableCollection<RenderOutputConfig>(doc.GetAvailableExportOutputs().Select(x => new RenderOutputConfig(x.name, x.originalSize)));
+        VecI size = doc.GetDefaultRenderSize(out string? renderOutputName);
+        FileWidth = size.X;
+        FileHeight = size.Y;
+        ExportOutput = new RenderOutputConfig(renderOutputName, size);
 
         document = doc;
     }
@@ -74,26 +78,26 @@ internal class ExportFileDialog : CustomDialog
         }
     }
 
-    public int OffsetX
+    public ObservableCollection<RenderOutputConfig> AvailableExportOutputs
     {
-        get => offsetX;
+        get => availableExportOutputs;
         set
         {
-            if (offsetX != value)
+            if (availableExportOutputs != value)
             {
-                this.SetProperty(ref offsetX, value);
+                this.SetProperty(ref availableExportOutputs, value);
             }
         }
     }
 
-    public int OffsetY
+    public RenderOutputConfig ExportOutput
     {
-        get => offsetY;
+        get => exportOutput;
         set
         {
-            if (offsetY != value)
+            if (exportOutput != value)
             {
-                this.SetProperty(ref offsetY, value);
+                this.SetProperty(ref exportOutput, value);
             }
         }
     }
@@ -124,7 +128,12 @@ internal class ExportFileDialog : CustomDialog
     
     public override async Task<bool> ShowDialog()
     {
-        ExportFilePopup popup = new ExportFilePopup(FileWidth, FileHeight, document) { SuggestedName = SuggestedName };
+        ExportFilePopup popup = new ExportFilePopup(FileWidth, FileHeight, document)
+        {
+            SuggestedName = SuggestedName,
+            AvailableExportOutputs = AvailableExportOutputs,
+            ExportOutput = ExportOutput,
+        };
         bool result = await popup.ShowDialog<bool>(OwnerWindow);
 
         if (result)
@@ -133,8 +142,10 @@ internal class ExportFileDialog : CustomDialog
             FileHeight = popup.SaveHeight;
             FilePath = popup.SavePath;
             ChosenFormat = popup.SaveFormat;
+            ExportOutput = popup.ExportOutput;
             
             ExportConfig.ExportSize = new VecI(FileWidth, FileHeight);
+            ExportConfig.ExportOutput = ExportOutput.Name;
             ExportConfig.AnimationRenderer = ChosenFormat is VideoFileType ? new FFMpegRenderer()
             {
                 Size = new VecI(FileWidth, FileHeight),
@@ -159,3 +170,15 @@ internal class ExportFileDialog : CustomDialog
         return result;
     }
 }
+
+public record RenderOutputConfig
+{
+    public string Name { get; set; }
+    public VecI OriginalSize { get; set; }
+
+    public RenderOutputConfig(string name, VecI originalSize)
+    {
+        Name = name;
+        OriginalSize = originalSize;
+    }
+}

+ 18 - 5
src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml

@@ -24,8 +24,8 @@
                     </Style>
                 </TabControl.Styles>
                 <TabControl.Items>
-                    <TabItem IsSelected="True" ui1:Translator.Key="EXPORT_IMAGE_HEADER"/>
-                    <TabItem ui1:Translator.Key="EXPORT_ANIMATION_HEADER"/>
+                    <TabItem IsSelected="True" ui1:Translator.Key="EXPORT_IMAGE_HEADER" />
+                    <TabItem ui1:Translator.Key="EXPORT_ANIMATION_HEADER" />
                     <TabItem ui1:Translator.Key="EXPORT_SPRITESHEET_HEADER">
                         <Grid>
                             <Grid.ColumnDefinitions>
@@ -60,17 +60,30 @@
                         <Grid.RowDefinitions>
                             <RowDefinition Height="Auto" />
                             <RowDefinition Height="Auto" />
+                            <RowDefinition Height="Auto" />
                         </Grid.RowDefinitions>
-                        <input:SizePicker Grid.Row="0"
+                        <DockPanel LastChildFill="True" HorizontalSpacing="5" Width="186">
+                            <TextBlock ui1:Translator.Key="EXPORT_OUTPUT" VerticalAlignment="Center" />
+                            <ComboBox VerticalAlignment="Center"
+                                      SelectedItem="{Binding ElementName=saveFilePopup, Path=ExportOutput}"
+                                      ItemsSource="{Binding ElementName=saveFilePopup, Path=AvailableExportOutputs}">
+                                <ComboBox.ItemTemplate>
+                                    <DataTemplate>
+                                        <TextBlock ui1:Translator.Key="{Binding Path=Name}" />
+                                    </DataTemplate>
+                                </ComboBox.ItemTemplate>
+                            </ComboBox>
+                        </DockPanel>
+                        <input:SizePicker Grid.Row="1"
                                           x:Name="sizePicker"
                                           IsSizeUnitSelectionVisible="True"
                                           VerticalAlignment="Top"
                                           ChosenHeight="{Binding Path=SaveHeight, Mode=TwoWay, ElementName=saveFilePopup}"
                                           ChosenWidth="{Binding Path=SaveWidth, Mode=TwoWay, ElementName=saveFilePopup}" />
-                        <TextBlock Grid.Row="1" Margin="5, 0" VerticalAlignment="Bottom" Classes="hyperlink"
+                        <TextBlock Grid.Row="2" Margin="5, 0" VerticalAlignment="Bottom" Classes="hyperlink"
                                    TextWrapping="Wrap"
                                    Width="220" TextAlignment="Center"
-                                   Text="{Binding SizeHint, Mode=OneTime, ElementName=saveFilePopup}">
+                                   Text="{Binding SizeHint, ElementName=saveFilePopup}">
                             <Interaction.Behaviors>
                                 <EventTriggerBehavior EventName="PointerPressed">
                                     <InvokeCommandAction

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

@@ -1,4 +1,5 @@
-using System.ComponentModel;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
 using System.Threading.Tasks;
 using Avalonia;
 using Avalonia.Controls;
@@ -63,13 +64,34 @@ internal partial class ExportFilePopup : PixiEditorPopup
         AvaloniaProperty.Register<ExportFilePopup, int>(
             nameof(SpriteSheetRows), 1);
 
-    public static readonly StyledProperty<string> ExportZoneProperty = AvaloniaProperty.Register<ExportFilePopup, string>(
-        nameof(ExportZone));
+    public static readonly StyledProperty<RenderOutputConfig> ExportOutputProperty =
+        AvaloniaProperty.Register<ExportFilePopup, RenderOutputConfig>(
+            nameof(ExportOutput));
 
-    public string ExportZone
+    public static readonly StyledProperty<ObservableCollection<RenderOutputConfig>>
+        AvailableExportOutputsProperty =
+            AvaloniaProperty.Register<ExportFilePopup, ObservableCollection<RenderOutputConfig>>(
+                nameof(AvailableExportOutputs));
+
+    public static readonly StyledProperty<string> SizeHintProperty = AvaloniaProperty.Register<ExportFilePopup, string>(
+        nameof(SizeHint));
+
+    public string SizeHint
+    {
+        get => GetValue(SizeHintProperty);
+        set => SetValue(SizeHintProperty, value);
+    }
+
+    public ObservableCollection<RenderOutputConfig> AvailableExportOutputs
     {
-        get => GetValue(ExportZoneProperty);
-        set => SetValue(ExportZoneProperty, value);
+        get => GetValue(AvailableExportOutputsProperty);
+        set => SetValue(AvailableExportOutputsProperty, value);
+    }
+
+    public RenderOutputConfig ExportOutput
+    {
+        get => GetValue(ExportOutputProperty);
+        set => SetValue(ExportOutputProperty, value);
     }
 
     public int SpriteSheetRows
@@ -149,14 +171,14 @@ internal partial class ExportFilePopup : PixiEditorPopup
 
     public bool IsSpriteSheetExport => SelectedExportIndex == 2;
 
-    public string SizeHint => new LocalizedString("EXPORT_SIZE_HINT", GetBestPercentage());
-
     private DocumentViewModel document;
     private Image[]? videoPreviewFrames = [];
     private DispatcherTimer videoPreviewTimer = new DispatcherTimer();
     private int activeFrame = 0;
     private CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
 
+    private Task? generateSpriteSheetTask;
+
     static ExportFilePopup()
     {
         SaveWidthProperty.Changed.Subscribe(RerenderPreview);
@@ -164,6 +186,7 @@ internal partial class ExportFilePopup : PixiEditorPopup
         SpriteSheetColumnsProperty.Changed.Subscribe(RerenderPreview);
         SpriteSheetRowsProperty.Changed.Subscribe(RerenderPreview);
         SelectedExportIndexProperty.Changed.Subscribe(RerenderPreview);
+        ExportOutputProperty.Changed.Subscribe(UpdateOutput);
     }
 
     public ExportFilePopup(int imageWidth, int imageHeight, DocumentViewModel document)
@@ -175,9 +198,6 @@ internal partial class ExportFilePopup : PixiEditorPopup
         DataContext = this;
         Loaded += (_, _) => sizePicker.FocusWidthPicker();
 
-        SaveWidth = imageWidth;
-        SaveHeight = imageHeight;
-
         SetBestPercentageCommand = new RelayCommand(SetBestPercentage);
         ExportCommand = new AsyncRelayCommand(Export);
         this.document = document;
@@ -193,9 +213,15 @@ internal partial class ExportFilePopup : PixiEditorPopup
         SpriteSheetColumns = columns;
         SpriteSheetRows = rows;
 
+        UpdateSizeHint();
         RenderPreview();
     }
 
+    public void UpdateSizeHint()
+    {
+        SizeHint = new LocalizedString("EXPORT_SIZE_HINT", GetBestPercentage());
+    }
+
     protected override void OnClosing(WindowClosingEventArgs e)
     {
         base.OnClosing(e);
@@ -224,7 +250,7 @@ internal partial class ExportFilePopup : PixiEditorPopup
             {
                 return;
             }
-            
+
             ExportPreview.DrawingSurface.Canvas.DrawImage(videoPreviewFrames[activeFrame], 0, 0);
             activeFrame = (activeFrame + 1) % videoPreviewFrames.Length;
         }
@@ -266,9 +292,17 @@ internal partial class ExportFilePopup : PixiEditorPopup
             }
             else
             {
+                var exportOutput = ExportOutput;
+                if (exportOutput == null || exportOutput.OriginalSize.ShortestAxis <= 0)
+                {
+                    return;
+                }
+
+                VecI size = new VecI(SaveWidth, SaveHeight);
                 Task.Run(() =>
                 {
-                    var rendered = document.TryRenderWholeImage(0);
+                    var rendered = document.TryRenderWholeImage(0, size, exportOutput.OriginalSize,
+                        exportOutput.Name);
                     if (rendered.IsT1)
                     {
                         VecI previewSize = CalculatePreviewSize(rendered.AsT1.Size);
@@ -295,12 +329,25 @@ internal partial class ExportFilePopup : PixiEditorPopup
         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));
-        if (previewSize != ExportPreview.Size)
+        cancellationTokenSource?.Cancel();
+        cancellationTokenSource = new CancellationTokenSource();
+
+        if (generateSpriteSheetTask != null)
         {
-            ExportPreview?.Dispose();
-            ExportPreview = Surface.ForDisplay(previewSize);
+            generateSpriteSheetTask.ContinueWith(x =>
+            {
+                Dispatcher.UIThread.Invoke(GenerateSpriteSheetPreview);
+            });
+
+            return;
+        }
 
-            Task.Run(() =>
+        ExportPreview?.Dispose();
+        ExportPreview = Surface.ForDisplay(previewSize);
+
+        string exportOutputName = ExportOutput.Name;
+        generateSpriteSheetTask = Task.Run(
+            () =>
             {
                 try
                 {
@@ -309,7 +356,8 @@ internal partial class ExportFilePopup : PixiEditorPopup
                         {
                             int x = index % clampedColumns;
                             int y = index / clampedColumns;
-                            var resized = frame.ResizeNearestNeighbor(new VecI(singleFrameSize.X, singleFrameSize.Y));
+                            var resized =
+                                frame.ResizeNearestNeighbor(new VecI(singleFrameSize.X, singleFrameSize.Y));
                             DrawingBackendApi.Current.RenderingDispatcher.Invoke(() =>
                             {
                                 if (ExportPreview.IsDisposed) return;
@@ -318,14 +366,13 @@ internal partial class ExportFilePopup : PixiEditorPopup
                                     y * singleFrameSize.Y);
                                 resized.Dispose();
                             });
-                        }, cancellationTokenSource.Token);
+                        }, cancellationTokenSource.Token, exportOutputName);
                 }
-                catch 
+                catch
                 {
                     // Ignore
                 }
-            });
-        }
+            }, cancellationTokenSource.Token).ContinueWith(x => generateSpriteSheetTask = null);
     }
 
     private void StartRenderAnimationJob()
@@ -337,10 +384,12 @@ internal partial class ExportFilePopup : PixiEditorPopup
 
         cancellationTokenSource = new CancellationTokenSource();
 
+        string exportOutputName = ExportOutput.Name;
         Task.Run(
             () =>
             {
-                videoPreviewFrames = document.RenderFrames(ProcessFrame, cancellationTokenSource.Token);
+                videoPreviewFrames =
+                    document.RenderFrames(ProcessFrame, exportOutputName, cancellationTokenSource.Token);
             }, cancellationTokenSource.Token).ContinueWith(_ =>
         {
             Dispatcher.UIThread.Invoke(() =>
@@ -394,7 +443,7 @@ internal partial class ExportFilePopup : PixiEditorPopup
 
             int newWidth = (int)(imageSize.X * scale);
             int newHeight = (int)(imageSize.Y * scale);
-            
+
             newWidth = Math.Max(newWidth, 1);
             newHeight = Math.Max(newHeight, 1);
 
@@ -422,8 +471,9 @@ internal partial class ExportFilePopup : PixiEditorPopup
         {
             Title = new LocalizedString("EXPORT_SAVE_TITLE"),
             SuggestedFileName = SuggestedName,
-            SuggestedStartLocation = string.IsNullOrEmpty(document.FullFilePath) ? await GetTopLevel(this).StorageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents) : await GetTopLevel(this).StorageProvider.TryGetFolderFromPathAsync(document.FullFilePath),
-                
+            SuggestedStartLocation = string.IsNullOrEmpty(document.FullFilePath)
+                ? await GetTopLevel(this).StorageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents)
+                : await GetTopLevel(this).StorageProvider.TryGetFolderFromPathAsync(document.FullFilePath),
             FileTypeChoices =
                 SupportedFilesHelper.BuildSaveFilter(SelectedExportIndex == 1
                     ? FileTypeDialogDataSet.SetKind.Video
@@ -483,4 +533,15 @@ internal partial class ExportFilePopup : PixiEditorPopup
             }
         }
     }
+
+    private static void UpdateOutput(AvaloniaPropertyChangedEventArgs e)
+    {
+        if (e.Sender is ExportFilePopup popup)
+        {
+            popup.SaveWidth = popup.ExportOutput.OriginalSize.X;
+            popup.SaveHeight = popup.ExportOutput.OriginalSize.Y;
+            popup.RenderPreview();
+            popup.UpdateSizeHint();
+        }
+    }
 }