Browse Source

Merge pull request #938 from PixiEditor/fixes/12.05.2025

Fixes/12.05.2025
Krzysztof Krysiński 3 months ago
parent
commit
f125ac4197
37 changed files with 672 additions and 104 deletions
  1. 1 1
      src/Drawie
  2. 4 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/NodeGraph/ComputedPropertyValue_ChangeInfo.cs
  3. 2 2
      src/PixiEditor.ChangeableDocument/Changeables/Graph/InputProperty.cs
  4. 5 3
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CreateImageNode.cs
  5. 1 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/SampleImageNode.cs
  6. 2 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/TextureCache.cs
  7. 8 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/VectorLayerNode.cs
  8. 11 7
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Workspace/CustomOutputNode.cs
  9. 49 0
      src/PixiEditor.ChangeableDocument/Changes/NodeGraph/EvaluateGraph_Change.cs
  10. 80 0
      src/PixiEditor.ChangeableDocument/Changes/NodeGraph/GetComputedPropertyValue_Change.cs
  11. 2 1
      src/PixiEditor.ChangeableDocument/Rendering/RenderingUtils.cs
  12. 12 3
      src/PixiEditor/Data/Localization/Languages/en.json
  13. 37 0
      src/PixiEditor/Helpers/Converters/ComputedValueToStringConverter.cs
  14. 36 0
      src/PixiEditor/Models/DocumentModels/DocumentUpdater.cs
  15. 3 3
      src/PixiEditor/Models/Files/ImageFileType.cs
  16. 1 1
      src/PixiEditor/Models/Files/Mp4FileType.cs
  17. 3 2
      src/PixiEditor/Models/Files/OtfFileType.cs
  18. 2 1
      src/PixiEditor/Models/Files/SvgFileType.cs
  19. 3 2
      src/PixiEditor/Models/Files/TtfFileType.cs
  20. 2 2
      src/PixiEditor/Models/Files/VideoFileType.cs
  21. 1 1
      src/PixiEditor/Models/Files/WebpFileType.cs
  22. 1 0
      src/PixiEditor/Models/Handlers/INodeGraphHandler.cs
  23. 3 0
      src/PixiEditor/Models/Handlers/INodePropertyHandler.cs
  24. 1 0
      src/PixiEditor/Models/IO/ExportConfig.cs
  25. 4 1
      src/PixiEditor/Styles/Templates/NodeSocket.axaml
  26. 181 31
      src/PixiEditor/ViewModels/Document/DocumentViewModel.cs
  27. 6 0
      src/PixiEditor/ViewModels/Document/NodeGraphViewModel.cs
  28. 0 8
      src/PixiEditor/ViewModels/Document/Nodes/CustomOutputNodeViewModel.cs
  29. 7 0
      src/PixiEditor/ViewModels/Document/Nodes/Workspace/CustomOutputNodeViewModel.cs
  30. 25 4
      src/PixiEditor/ViewModels/Nodes/NodePropertyViewModel.cs
  31. 1 1
      src/PixiEditor/ViewModels/SubViewModels/FileViewModel.cs
  32. 6 0
      src/PixiEditor/ViewModels/SubViewModels/NodeGraphManagerViewModel.cs
  33. 55 3
      src/PixiEditor/Views/Dialogs/ExportFileDialog.cs
  34. 18 5
      src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml
  35. 91 21
      src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml.cs
  36. 7 0
      src/PixiEditor/Views/Nodes/Properties/NodeSocket.cs
  37. 1 0
      src/PixiEditor/Views/Palettes/ColorReplacer.axaml

+ 1 - 1
src/Drawie

@@ -1 +1 @@
-Subproject commit c53ef1e999160717ec52c9149a74d41d86da721b
+Subproject commit 9a9a3814a43945c70badcfafc9e936d18943df7c

+ 4 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/NodeGraph/ComputedPropertyValue_ChangeInfo.cs

@@ -0,0 +1,4 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
+
+public record ComputedPropertyValue_ChangeInfo(Guid Node, string PropertyName, bool IsInput, object? Value)
+    : IChangeInfo;

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

+ 5 - 3
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CreateImageNode.cs

@@ -3,6 +3,7 @@ using Drawie.Backend.Core;
 using Drawie.Backend.Core.Bridge;
 using Drawie.Backend.Core.ColorsImpl;
 using Drawie.Backend.Core.ColorsImpl.Paintables;
+using Drawie.Backend.Core.Numerics;
 using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces.ImageData;
 using Drawie.Backend.Core.Surfaces.PaintImpl;
@@ -22,7 +23,8 @@ public class CreateImageNode : Node, IPreviewRenderable
 
     public RenderInputProperty Content { get; }
 
-    public InputProperty<VecD> ContentOffset { get; }
+    public InputProperty<Matrix3X3> ContentMatrix { get; }
+
 
     public RenderOutputProperty RenderOutput { get; }
 
@@ -34,7 +36,7 @@ public class CreateImageNode : Node, IPreviewRenderable
         Size = CreateInput(nameof(Size), "SIZE", new VecI(32, 32)).WithRules(v => v.Min(VecI.One));
         Fill = CreateInput<Paintable>(nameof(Fill), "FILL", new ColorPaintable(Colors.Transparent));
         Content = CreateRenderInput(nameof(Content), "CONTENT");
-        ContentOffset = CreateInput(nameof(ContentOffset), "CONTENT_OFFSET", VecD.Zero);
+        ContentMatrix = CreateInput<Matrix3X3>(nameof(ContentMatrix), "MATRIX", Matrix3X3.Identity);
         RenderOutput = CreateRenderOutput("RenderOutput", "RENDER_OUTPUT", () => new Painter(OnPaint));
     }
 
@@ -72,7 +74,7 @@ public class CreateImageNode : Node, IPreviewRenderable
         RenderContext ctx = new RenderContext(surface.DrawingSurface, context.FrameTime, context.ChunkResolution,
             context.DocumentSize, context.ProcessingColorSpace);
 
-        surface.DrawingSurface.Canvas.Translate((float)-ContentOffset.Value.X, (float)-ContentOffset.Value.Y);
+        surface.DrawingSurface.Canvas.SetMatrix(surface.DrawingSurface.Canvas.TotalMatrix.Concat(ContentMatrix.Value));
 
         Content.Value?.Paint(ctx, surface.DrawingSurface);
 

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/SampleImageNode.cs

@@ -30,7 +30,7 @@ public class SampleImageNode : Node
 
     private Half4 GetColor(FuncContext context)
     {
-        if (Image.Value is null)
+        if (Image.Value is null || Image.Value.IsDisposed)
         {
             return new Half4("");
         }

+ 2 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/TextureCache.cs

@@ -1,5 +1,6 @@
 using Drawie.Backend.Core;
 using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Numerics;
 using Drawie.Backend.Core.Surfaces.ImageData;
 using Drawie.Numerics;
 
@@ -24,6 +25,7 @@ public class TextureCache : IDisposable
             if (clear)
             {
                 texture.DrawingSurface.Canvas.Clear(Colors.Transparent);
+                texture.DrawingSurface.Canvas.SetMatrix(Matrix3X3.Identity);
             }
 
             return texture;

+ 8 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/VectorLayerNode.cs

@@ -19,6 +19,7 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorNode, IRasterizable, IScalable
 {
     public OutputProperty<ShapeVectorData> Shape { get; }
+    public OutputProperty<Matrix3X3> Matrix { get; }
 
     public Matrix3X3 TransformationMatrix
     {
@@ -50,6 +51,13 @@ public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorN
     {
         AllowHighDpiRendering = true;
         Shape = CreateOutput<ShapeVectorData>("Shape", "SHAPE", null);
+        Matrix = CreateOutput<Matrix3X3>("Matrix", "MATRIX", Matrix3X3.Identity);
+    }
+
+    protected override void OnExecute(RenderContext context)
+    {
+        base.OnExecute(context);
+        Matrix.Value = TransformationMatrix;
     }
 
     protected override void DrawWithoutFilters(SceneObjectRenderContext ctx, DrawingSurface workingSurface,

+ 11 - 7
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CustomOutputNode.cs → src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Workspace/CustomOutputNode.cs

@@ -1,19 +1,21 @@
-using PixiEditor.ChangeableDocument.Changeables.Animations;
+using Drawie.Backend.Core.Surfaces;
+using Drawie.Numerics;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using PixiEditor.ChangeableDocument.Rendering;
-using Drawie.Backend.Core;
-using Drawie.Backend.Core.Surfaces;
-using Drawie.Numerics;
 
-namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Workspace;
 
 [NodeInfo("CustomOutput")]
 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()
     {
@@ -21,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()

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

+ 80 - 0
src/PixiEditor.ChangeableDocument/Changes/NodeGraph/GetComputedPropertyValue_Change.cs

@@ -0,0 +1,80 @@
+using Drawie.Backend.Core.Shaders.Generation;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Context;
+using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
+
+namespace PixiEditor.ChangeableDocument.Changes.NodeGraph;
+
+internal class GetComputedPropertyValue_Change : Change
+{
+    private readonly Guid nodeId;
+    private readonly string propertyName;
+    private readonly bool isInput;
+
+    [GenerateMakeChangeAction]
+    public GetComputedPropertyValue_Change(Guid nodeId, string propertyName, bool isInput)
+    {
+        this.nodeId = nodeId;
+        this.propertyName = propertyName;
+        this.isInput = isInput;
+    }
+
+    public override bool InitializeAndValidate(Document target)
+    {
+        var foundNode = target.FindNode(nodeId);
+        if (foundNode == null)
+        {
+            return false;
+        }
+
+        if (isInput)
+        {
+            return foundNode.InputProperties.Any(x => x.InternalPropertyName == propertyName);
+        }
+
+        return foundNode.OutputProperties.Any(x => x.InternalPropertyName == propertyName);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply,
+        out bool ignoreInUndo)
+    {
+        var node = target.FindNode(nodeId);
+        ignoreInUndo = true;
+
+        if (node == null)
+        {
+            return new None();
+        }
+
+        object value;
+        if (isInput)
+        {
+            value = node.GetInputProperty(propertyName).Value;
+        }
+        else
+        {
+            value = node.GetOutputProperty(propertyName).Value;
+        }
+        if (value is Delegate del)
+        {
+            try
+            {
+                value = del.DynamicInvoke(FuncContext.NoContext);
+            }
+            catch (Exception e)
+            {
+                return new None();
+            }
+        }
+        if(value is ShaderExpressionVariable variable)
+        {
+            value = variable.GetConstant();
+        }
+
+        return new ComputedPropertyValue_ChangeInfo(nodeId, propertyName, isInput, value);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        return new None();
+    }
+}

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

@@ -1,6 +1,7 @@
 using PixiEditor.ChangeableDocument.Changeables.Graph;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Workspace;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 
 namespace PixiEditor.ChangeableDocument.Rendering;
@@ -9,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;
         }

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

@@ -597,6 +597,9 @@
   "BMP_FILE": "BMP Images",
   "IMAGE_FILES": "Image Files",
   "VIDEO_FILES": "Video Files",
+  "OPEN_TYPE_FONT": "OpenType Fonts",
+  "TRUE_TYPE_FONT": "TrueType Fonts",
+  "SVG_FILE": "Scalable Vector Graphics",
   "MP4_FILE": "MP4 Videos",
   "COLUMNS": "Columns",
   "ROWS": "Rows",
@@ -812,8 +815,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",
@@ -1036,5 +1039,11 @@
   "AUTOSAVE_OPEN_FOLDER": "Open autosave folder",
    "AUTOSAVE_OPEN_FOLDER_DESCRIPTIVE": "Open the folder where autosaves are stored",
   "AUTOSAVE_TOGGLE_DESCRIPTIVE": "Enable/disable autosave",
-  "ERROR_GRAPH": "Graph Setup produced an error. Fix it the node graph"
+  "ERROR_GRAPH": "Graph setup produced an error. Fix it in the node graph",
+  "COLOR_MATRIX_FILTER_NODE": "Color Matrix Filter",
+  "WORKSPACE": "Workspace",
+  "EXPORT_ZONE_NODE": "Export Zone",
+  "IS_DEFAULT_EXPORT": "Is Default Export",
+  "EXPORT_OUTPUT": "Export Output",
+  "EXPORT_SIZE": "Export Size"
 }

+ 37 - 0
src/PixiEditor/Helpers/Converters/ComputedValueToStringConverter.cs

@@ -0,0 +1,37 @@
+using System.Globalization;
+using Drawie.Numerics;
+
+namespace PixiEditor.Helpers.Converters;
+
+internal class ComputedValueToStringConverter : SingleInstanceConverter<ComputedValueToStringConverter>
+{
+    public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+    {
+        if (value is double d)
+        {
+            return $"{d:0.##}";
+        }
+
+        if (value is float f)
+        {
+            return $"{f:0.##}";
+        }
+
+        if(value is VecD vecD)
+        {
+            return $"{vecD.X:0.##}, {vecD.Y:0.##}";
+        }
+
+        if (value is VecF vecI)
+        {
+            return $"{vecI.X:0.##}, {vecI.Y:0.##}";
+        }
+
+        if (value is VecI vec)
+        {
+            return $"{vec.X}, {vec.Y}";
+        }
+
+        return value?.ToString() ?? "null";
+    }
+}

+ 36 - 0
src/PixiEditor/Models/DocumentModels/DocumentUpdater.cs

@@ -23,6 +23,8 @@ using PixiEditor.Models.DocumentPassthroughActions;
 using PixiEditor.Models.Handlers;
 using PixiEditor.Models.Layers;
 using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Context;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Workspace;
 using PixiEditor.Models.Dialogs;
 using PixiEditor.Models.DocumentModels.UpdateableChangeExecutors.Features;
 using PixiEditor.ViewModels.Document;
@@ -223,6 +225,9 @@ internal class DocumentUpdater
             case MarkAsAutosaved_PassthroughAction info:
                 MarkAsAutosaved(info);
                 break;
+            case ComputedPropertyValue_ChangeInfo info:
+                ProcessComputedPropertyValue(info);
+                break;
         }
     }
 
@@ -871,4 +876,35 @@ internal class DocumentUpdater
     {
         doc.InternalMarkSaveState(info.Type);
     }
+
+    private void ProcessComputedPropertyValue(ComputedPropertyValue_ChangeInfo info)
+    {
+        object finalValue = info.Value;
+        if (info.Value != null && !info.Value.GetType().IsValueType && info.Value is not string)
+        {
+            bool valueToStringIsDefault = info.Value.GetType().FullName == info.Value.ToString();
+            if (valueToStringIsDefault)
+            {
+                finalValue = info.Value?.GetType().Name ?? finalValue;
+            }
+        }
+
+        NodeViewModel node = doc.StructureHelper.FindNode<NodeViewModel>(info.Node);
+        INodePropertyHandler property;
+        if (info.IsInput)
+        {
+            property = node.FindInputProperty(info.PropertyName);
+        }
+        else
+        {
+            property = node.FindOutputProperty(info.PropertyName);
+        }
+
+        if (property is null)
+        {
+            return;
+        }
+
+        property.InternalSetComputedValue(finalValue);
+    }
 }

+ 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 - 1
src/PixiEditor/Models/Files/Mp4FileType.cs

@@ -5,5 +5,5 @@ namespace PixiEditor.Models.Files;
 internal class Mp4FileType : VideoFileType
 {
     public override string[] Extensions { get; } = { ".mp4" };
-    public override string DisplayName { get; } = new LocalizedString("MP4_FILE");
+    public override string DisplayName => new LocalizedString("MP4_FILE");
 }

+ 3 - 2
src/PixiEditor/Models/Files/OtfFileType.cs

@@ -1,4 +1,5 @@
-using PixiEditor.Models.IO;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.IO;
 using PixiEditor.ViewModels.Document;
 
 namespace PixiEditor.Models.Files;
@@ -6,7 +7,7 @@ namespace PixiEditor.Models.Files;
 internal class OtfFileType : IoFileType
 {
     public override string[] Extensions { get; } = new[] { ".otf" };
-    public override string DisplayName { get; } = "OpenType Font";
+    public override string DisplayName => new LocalizedString("OPEN_TYPE_FONT");
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Vector;
 
     public override bool CanSave => false;

+ 2 - 1
src/PixiEditor/Models/Files/SvgFileType.cs

@@ -3,6 +3,7 @@ using PixiEditor.ChangeableDocument.Rendering;
 using PixiEditor.Models.Handlers;
 using PixiEditor.Models.IO;
 using Drawie.Numerics;
+using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.SVG;
 using PixiEditor.SVG.Elements;
 using PixiEditor.SVG.Features;
@@ -14,7 +15,7 @@ namespace PixiEditor.Models.Files;
 internal class SvgFileType : IoFileType
 {
     public override string[] Extensions { get; } = new[] { ".svg" };
-    public override string DisplayName { get; } = "Scalable Vector Graphics";
+    public override string DisplayName => new LocalizedString("SVG_FILE");
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Vector;
     public override SolidColorBrush EditorColor { get; } = new SolidColorBrush(Color.FromRgb(0, 128, 0));
 

+ 3 - 2
src/PixiEditor/Models/Files/TtfFileType.cs

@@ -1,4 +1,5 @@
-using PixiEditor.Models.IO;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.IO;
 using PixiEditor.ViewModels.Document;
 
 namespace PixiEditor.Models.Files;
@@ -6,7 +7,7 @@ namespace PixiEditor.Models.Files;
 internal class TtfFileType : IoFileType
 {
     public override string[] Extensions { get; } = new[] { ".ttf" };
-    public override string DisplayName { get; } = "TrueType Font";
+    public override string DisplayName => new LocalizedString("TRUE_TYPE_FONT");
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Vector;
 
     public override bool CanSave => false;

+ 2 - 2
src/PixiEditor/Models/Files/VideoFileType.cs

@@ -35,7 +35,7 @@ internal abstract class VideoFileType : IoFileType
             }
 
             return surface;
-        });
+        }, config.ExportOutput);
 
         job?.Report(0.5, new LocalizedString("RENDERING_VIDEO"));
         CancellationToken token = job?.CancellationTokenSource.Token ?? CancellationToken.None;
@@ -79,7 +79,7 @@ internal abstract class VideoFileType : IoFileType
             }
 
             return surface;
-        });
+        }, config.ExportOutput);
 
         job?.Report(0.5, new LocalizedString("RENDERING_VIDEO"));
         CancellationToken token = job?.CancellationTokenSource.Token ?? CancellationToken.None;

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

@@ -8,7 +8,7 @@ internal class WebpFileType : ImageFileType
 {
     public override string[] Extensions { get; } = [".webp"];
 
-    public override string DisplayName { get; } = new LocalizedString("WEBP_FILE");
+    public override string DisplayName => new LocalizedString("WEBP_FILE");
 
     public override EncodedImageFormat EncodedImageFormat { get; } = EncodedImageFormat.Webp;
 

+ 1 - 0
src/PixiEditor/Models/Handlers/INodeGraphHandler.cs

@@ -21,4 +21,5 @@ internal interface INodeGraphHandler
    public void RemoveConnection(Guid nodeId, string property);
    public void RemoveConnections(Guid nodeId);
    public void UpdateAvailableRenderOutputs();
+   public void GetComputedPropertyValue(INodePropertyHandler property);
 }

+ 3 - 0
src/PixiEditor/Models/Handlers/INodePropertyHandler.cs

@@ -9,6 +9,7 @@ public interface INodePropertyHandler
     public string PropertyName { get; set; }
     public string DisplayName { get; set; }
     public object Value { get; set; }
+    public object ComputedValue { get; set; }
     public bool IsInput { get; }
     public INodePropertyHandler? ConnectedOutput { get; set; }
     public ObservableCollection<INodePropertyHandler> ConnectedInputs { get; }
@@ -16,4 +17,6 @@ public interface INodePropertyHandler
     public event NodePropertyValueChanged ValueChanged;
     public INodeHandler Node { get; set; }
     public Type PropertyType { get; }
+    public void UpdateComputedValue();
+    public void InternalSetComputedValue(object value);
 }

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

+ 4 - 1
src/PixiEditor/Styles/Templates/NodeSocket.axaml

@@ -1,17 +1,20 @@
 <ResourceDictionary xmlns="https://github.com/avaloniaui"
                     xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
-                    xmlns:properties="clr-namespace:PixiEditor.Views.Nodes.Properties">
+                    xmlns:properties="clr-namespace:PixiEditor.Views.Nodes.Properties"
+                    xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters">
     <ControlTheme TargetType="properties:NodeSocket" x:Key="{x:Type properties:NodeSocket}">
         <Setter Property="Template">
             <ControlTemplate>
                 <StackPanel Orientation="Horizontal" VerticalAlignment="Center">
                     <Grid Name="PART_ConnectPort">
                         <Panel Width="20" Height="20" Margin="-5, 0" Background="Transparent"
+                               ToolTip.ShowDelay="0" ToolTip.ShowOnDisabled="True" ToolTip.Tip="{Binding Property.ComputedValue, RelativeSource={RelativeSource TemplatedParent}, Converter={converters:ComputedValueToStringConverter}}"
                                IsVisible="{Binding !IsFunc, RelativeSource={RelativeSource TemplatedParent}}">
                             <Ellipse Width="10" Height="10" RenderTransform="rotate(90deg)"
                                      Fill="{TemplateBinding SocketBrush}" />
                         </Panel>
                         <Panel Margin="-5, 0" Width="20" Height="20" Background="Transparent"
+                               ToolTip.ShowDelay="0" ToolTip.ShowOnDisabled="True" ToolTip.Tip="{Binding Property.ComputedValue, RelativeSource={RelativeSource TemplatedParent}, Converter={converters:ComputedValueToStringConverter}}"
                                IsVisible="{Binding IsFunc, RelativeSource={RelativeSource TemplatedParent}}">
                             <Rectangle Width="10" Height="10"
                                        RadiusX="2" RadiusY="2"

+ 181 - 31
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,25 +28,22 @@ 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;
 using PixiEditor.Models.Structures;
 using PixiEditor.Models.Tools;
 using Drawie.Numerics;
-using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Workspace;
 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;
 
@@ -600,6 +581,128 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         }
     }
 
+
+    public (string name, VecI originalSize)[] GetAvailableExportOutputs()
+    {
+        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;
+        }
+
+        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 SizeBindable;
+
+        var exportNode = exportNodes.FirstOrDefault();
+
+        if (exportNode is null)
+            return SizeBindable;
+
+        var exportSize =
+            exportNode.Inputs.FirstOrDefault(x => x.PropertyName == CustomOutputNode.ExportSizePropertyName);
+
+        if (exportSize is null)
+            return SizeBindable;
+
+        if (exportSize.ComputedValue is VecI finalSize)
+        {
+            if (exportNode.Inputs.FirstOrDefault(x => x.PropertyName == CustomOutputNode.OutputNamePropertyName) is
+                { } name)
+            {
+                renderOutputName = name.ComputedValue?.ToString();
+            }
+
+            return finalSize;
+        }
+
+        return SizeBindable;
+    }
+
     public ICrossDocumentPipe<T> ShareNode<T>(Guid layerId) where T : class, IReadOnlyNode
     {
         return Internals.Tracker.Document.CreateNodePipe<T>(layerId);
@@ -607,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;
@@ -614,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();
             });
@@ -1034,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 [];
@@ -1054,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;
@@ -1076,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();
@@ -1092,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;
@@ -1103,7 +1253,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         }
     }
 
-    public bool RenderFrames(List<Image> frames, Func<Surface, Surface> processFrameAction = null)
+    public bool RenderFrames(List<Image> frames, Func<Surface, Surface> processFrameAction = null, string? renderOutput = null)
     {
         var firstFrame = AnimationDataViewModel.GetFirstVisibleFrame();
         var lastFrame = AnimationDataViewModel.GetLastVisibleFrame();
@@ -1111,7 +1261,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         for (int i = firstFrame; i < lastFrame; i++)
         {
             KeyFrameTime frameTime = new KeyFrameTime(i, (double)(i - firstFrame) / (lastFrame - firstFrame));
-            var surface = TryRenderWholeImage(frameTime);
+            var surface = TryRenderWholeImage(frameTime, renderOutput);
             if (surface.IsT0)
             {
                 return false;

+ 6 - 0
src/PixiEditor/ViewModels/Document/NodeGraphViewModel.cs

@@ -12,6 +12,7 @@ using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
 using PixiEditor.Models.DocumentModels;
 using PixiEditor.Models.Handlers;
 using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Workspace;
 using PixiEditor.ViewModels.Nodes;
 
 namespace PixiEditor.ViewModels.Document;
@@ -223,6 +224,11 @@ internal class NodeGraphViewModel : ViewModelBase, INodeGraphHandler, IDisposabl
         Internals.ActionAccumulator.AddFinishedActions(new UpdatePropertyValue_Action(node.Id, property, value));
     }
 
+    public void GetComputedPropertyValue(INodePropertyHandler property)
+    {
+        Internals.ActionAccumulator.AddFinishedActions(new GetComputedPropertyValue_Action(property.Node.Id, property.PropertyName, property.IsInput));
+    }
+
     public void EndChangeNodePosition()
     {
         Internals.ActionAccumulator.AddFinishedActions(new EndNodePosition_Action());

+ 0 - 8
src/PixiEditor/ViewModels/Document/Nodes/CustomOutputNodeViewModel.cs

@@ -1,8 +0,0 @@
-using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
-using PixiEditor.Extensions.Common.Localization;
-using PixiEditor.ViewModels.Nodes;
-
-namespace PixiEditor.ViewModels.Document.Nodes;
-
-[NodeViewModel("CUSTOM_OUTPUT_NODE", null, "\uE81A")]
-internal class CustomOutputNodeViewModel : NodeViewModel<CustomOutputNode>;

+ 7 - 0
src/PixiEditor/ViewModels/Document/Nodes/Workspace/CustomOutputNodeViewModel.cs

@@ -0,0 +1,7 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Workspace;
+using PixiEditor.ViewModels.Nodes;
+
+namespace PixiEditor.ViewModels.Document.Nodes.Workspace;
+
+[NodeViewModel("CUSTOM_OUTPUT_NODE", "WORKSPACE", "\uE81A")]
+internal class CustomOutputNodeViewModel : NodeViewModel<CustomOutputNode>;

+ 25 - 4
src/PixiEditor/ViewModels/Nodes/NodePropertyViewModel.cs

@@ -1,10 +1,7 @@
 using System.Collections.ObjectModel;
 using Avalonia;
 using Avalonia.Media;
-using Avalonia.Styling;
-using Avalonia.Threading;
 using Drawie.Backend.Core.Shaders.Generation;
-using PixiEditor.Models.DocumentModels;
 using PixiEditor.Models.Events;
 using PixiEditor.Models.Handlers;
 using PixiEditor.ViewModels.Nodes.Properties;
@@ -23,6 +20,8 @@ internal abstract class NodePropertyViewModel : ViewModelBase, INodePropertyHand
     private IBrush socketBrush;
     private string errors = string.Empty;
 
+    private object computedValue;
+
     private ObservableCollection<INodePropertyHandler> connectedInputs = new();
     private INodePropertyHandler? connectedOutput;
 
@@ -41,13 +40,25 @@ internal abstract class NodePropertyViewModel : ViewModelBase, INodePropertyHand
         {
             var oldValue = _value;
             ViewModelMain.Current.NodeGraphManager.UpdatePropertyValue((node, PropertyName, value));
-            if(SetProperty(ref _value, value))
+            if (SetProperty(ref _value, value))
             {
                 ValueChanged?.Invoke(this, new NodePropertyValueChangedArgs(oldValue, value));
             }
         }
     }
 
+    public object ComputedValue
+    {
+        get
+        {
+            return computedValue;
+        }
+        set
+        {
+            SetProperty(ref computedValue, value);
+        }
+    }
+
     public bool IsInput
     {
         get => isInput;
@@ -184,6 +195,16 @@ internal abstract class NodePropertyViewModel : ViewModelBase, INodePropertyHand
         return (NodePropertyViewModel)Activator.CreateInstance(viewModelType, node, type);
     }
 
+    public void UpdateComputedValue()
+    {
+        ViewModelMain.Current.NodeGraphManager.GetComputedPropertyValue(this);
+    }
+
+    public void InternalSetComputedValue(object value)
+    {
+        computedValue = value;
+        OnPropertyChanged(nameof(ComputedValue));
+    }
 
     public void InternalSetValue(object? value)
     {

+ 1 - 1
src/PixiEditor/ViewModels/SubViewModels/FileViewModel.cs

@@ -589,7 +589,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
             if (doc is null)
                 return;
 
-            ExportFileDialog info = new ExportFileDialog(MainWindow.Current, doc.SizeBindable, doc)
+            ExportFileDialog info = new ExportFileDialog(MainWindow.Current, doc)
             {
                 SuggestedName = Path.GetFileNameWithoutExtension(doc.FileName)
             };

+ 6 - 0
src/PixiEditor/ViewModels/SubViewModels/NodeGraphManagerViewModel.cs

@@ -90,4 +90,10 @@ internal class NodeGraphManagerViewModel : SubViewModel<ViewModelMain>
     {
         Owner.DocumentManagerSubViewModel.ActiveDocument?.NodeGraph.EndChangeNodePosition();
     }
+
+    [Command.Internal("PixiEditor.NodeGraph.GetComputedPropertyValue")]
+    public void GetComputedPropertyValue(INodePropertyHandler property)
+    {
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.NodeGraph.GetComputedPropertyValue(property);
+    }
 }

+ 55 - 3
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,16 +21,24 @@ internal class ExportFileDialog : CustomDialog
 
     private int fileWidth;
 
+    private RenderOutputConfig exportOutput;
+
     private string suggestedName;
+
+    private ObservableCollection<RenderOutputConfig> availableExportOutputs = new ObservableCollection<RenderOutputConfig>();
     
     private DocumentViewModel document;
     
     public ExportConfig ExportConfig { get; set; } = new ExportConfig(VecI.Zero);
 
-    public ExportFileDialog(Window owner, VecI size, DocumentViewModel doc) : base(owner)
+    public ExportFileDialog(Window owner, DocumentViewModel doc) : base(owner)
     {
+        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;
     }
 
@@ -69,6 +78,30 @@ internal class ExportFileDialog : CustomDialog
         }
     }
 
+    public ObservableCollection<RenderOutputConfig> AvailableExportOutputs
+    {
+        get => availableExportOutputs;
+        set
+        {
+            if (availableExportOutputs != value)
+            {
+                this.SetProperty(ref availableExportOutputs, value);
+            }
+        }
+    }
+
+    public RenderOutputConfig ExportOutput
+    {
+        get => exportOutput;
+        set
+        {
+            if (exportOutput != value)
+            {
+                this.SetProperty(ref exportOutput, value);
+            }
+        }
+    }
+
     public IoFileType ChosenFormat
     {
         get => _chosenFormat;
@@ -95,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)
@@ -104,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),
@@ -130,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

+ 91 - 21
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,6 +64,36 @@ internal partial class ExportFilePopup : PixiEditorPopup
         AvaloniaProperty.Register<ExportFilePopup, int>(
             nameof(SpriteSheetRows), 1);
 
+    public static readonly StyledProperty<RenderOutputConfig> ExportOutputProperty =
+        AvaloniaProperty.Register<ExportFilePopup, RenderOutputConfig>(
+            nameof(ExportOutput));
+
+    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(AvailableExportOutputsProperty);
+        set => SetValue(AvailableExportOutputsProperty, value);
+    }
+
+    public RenderOutputConfig ExportOutput
+    {
+        get => GetValue(ExportOutputProperty);
+        set => SetValue(ExportOutputProperty, value);
+    }
+
     public int SpriteSheetRows
     {
         get => GetValue(SpriteSheetRowsProperty);
@@ -140,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);
@@ -155,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)
@@ -166,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;
@@ -184,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);
@@ -215,7 +250,7 @@ internal partial class ExportFilePopup : PixiEditorPopup
             {
                 return;
             }
-            
+
             ExportPreview.DrawingSurface.Canvas.DrawImage(videoPreviewFrames[activeFrame], 0, 0);
             activeFrame = (activeFrame + 1) % videoPreviewFrames.Length;
         }
@@ -257,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);
@@ -286,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;
+        }
+
+        ExportPreview?.Dispose();
+        ExportPreview = Surface.ForDisplay(previewSize);
 
-            Task.Run(() =>
+        string exportOutputName = ExportOutput.Name;
+        generateSpriteSheetTask = Task.Run(
+            () =>
             {
                 try
                 {
@@ -300,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;
@@ -309,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()
@@ -328,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(() =>
@@ -385,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);
 
@@ -413,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
@@ -474,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();
+        }
+    }
 }

+ 7 - 0
src/PixiEditor/Views/Nodes/Properties/NodeSocket.cs

@@ -52,6 +52,8 @@ public class NodeSocket : TemplatedControl
         ConnectPort.PointerPressed += ConnectPortOnPointerPressed;
         ConnectPort.PointerReleased += ConnectPortOnPointerReleased;
         ConnectPort.PointerMoved += ConnectPortOnPointerMoved;
+        ConnectPort.PointerEntered += ConnectPortOnPointerEntered;
+
     }
 
     private void ConnectPortOnPointerPressed(object? sender, PointerPressedEventArgs e)
@@ -70,5 +72,10 @@ public class NodeSocket : TemplatedControl
         e.Source = this;
         e.Pointer.Capture(null);
     }
+
+    private void ConnectPortOnPointerEntered(object? sender, PointerEventArgs e)
+    {
+        Property.UpdateComputedValue();
+    }
 }
 

+ 1 - 0
src/PixiEditor/Views/Palettes/ColorReplacer.axaml

@@ -42,6 +42,7 @@
                                FontSize="24" Margin="10 0" ui:Translator.UseLanguageFlowDirection="True"/>
                     <colorPicker:PortableColorPicker
                         UseHintColor="True"
+                        EnableGradientsTab="False"
                         SelectedColor="{Binding ElementName=uc, Path=NewColor, Mode=TwoWay}"
                         HintColor="{Binding ElementName=uc, Path=HintColor}"
                         Height="37"