Browse Source

Gif export is working

flabbet 1 year ago
parent
commit
415e6764e7
24 changed files with 319 additions and 118 deletions
  1. 1 1
      src/PixiEditor.AnimationRenderer.Core/IAnimationRenderer.cs
  2. 53 7
      src/PixiEditor.AnimationRenderer.FFmpeg/FFMpegRenderer.cs
  3. 1 0
      src/PixiEditor.AnimationRenderer.FFmpeg/PixiEditor.AnimationRenderer.FFmpeg.csproj
  4. 1 0
      src/PixiEditor.AvaloniaUI/Data/Localization/Languages/en.json
  5. 0 1
      src/PixiEditor.AvaloniaUI/Helpers/ServiceCollectionHelpers.cs
  6. 6 8
      src/PixiEditor.AvaloniaUI/Helpers/SupportedFilesHelper.cs
  7. 0 1
      src/PixiEditor.AvaloniaUI/Models/Files/BmpFileType.cs
  8. 1 4
      src/PixiEditor.AvaloniaUI/Models/Files/GifFileType.cs
  9. 6 3
      src/PixiEditor.AvaloniaUI/Models/Files/ImageFileType.cs
  10. 3 1
      src/PixiEditor.AvaloniaUI/Models/Files/IoFileType.cs
  11. 3 1
      src/PixiEditor.AvaloniaUI/Models/Files/PixiFileType.cs
  12. 27 0
      src/PixiEditor.AvaloniaUI/Models/Files/VideoFileType.cs
  13. 12 0
      src/PixiEditor.AvaloniaUI/Models/IO/ExportConfig.cs
  14. 8 8
      src/PixiEditor.AvaloniaUI/Models/IO/Exporter.cs
  15. 15 3
      src/PixiEditor.AvaloniaUI/Models/IO/FileTypeDialogDataSet.cs
  16. 3 3
      src/PixiEditor.AvaloniaUI/Models/Rendering/AffectedAreasGatherer.cs
  17. 14 0
      src/PixiEditor.AvaloniaUI/ViewModels/Document/AnimationDataViewModel.cs
  18. 15 5
      src/PixiEditor.AvaloniaUI/ViewModels/Document/DocumentViewModel.cs
  19. 1 16
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/AnimationsViewModel.cs
  20. 28 14
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/FileViewModel.cs
  21. 1 1
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/LayersViewModel.cs
  22. 13 0
      src/PixiEditor.AvaloniaUI/Views/Dialogs/ExportFileDialog.cs
  23. 13 9
      src/PixiEditor.AvaloniaUI/Views/Dialogs/ExportFilePopup.axaml
  24. 94 32
      src/PixiEditor.AvaloniaUI/Views/Dialogs/ExportFilePopup.axaml.cs

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

@@ -2,5 +2,5 @@
 
 public interface IAnimationRenderer
 {
-    public Task<bool> RenderAsync(string framesPath, int frameRate = 60);
+    public Task<bool> RenderAsync(string framesPath, string outputPath);
 }

+ 53 - 7
src/PixiEditor.AnimationRenderer.FFmpeg/FFMpegRenderer.cs

@@ -1,13 +1,18 @@
-using FFMpegCore;
+using System.Drawing;
+using FFMpegCore;
 using FFMpegCore.Arguments;
 using FFMpegCore.Enums;
 using PixiEditor.AnimationRenderer.Core;
+using PixiEditor.Numerics;
 
 namespace PixiEditor.AnimationRenderer.FFmpeg;
 
 public class FFMpegRenderer : IAnimationRenderer
 {
-    public async Task<bool> RenderAsync(string framesPath, int frameRate = 60)
+    public int FrameRate { get; set; } = 60;
+    public string OutputFormat { get; set; } = "mp4";
+    public VecI Size { get; set; } 
+    public async Task<bool> RenderAsync(string framesPath, string outputPath)
     {
         string[] frames = Directory.GetFiles(framesPath, "*.png");
         if (frames.Length == 0)
@@ -15,16 +20,57 @@ public class FFMpegRenderer : IAnimationRenderer
             return false;
         }
         
+        string[] finalFrames = new string[frames.Length];
+
+        for (int i = 0; i < frames.Length; i++)
+        {
+            if(int.TryParse(Path.GetFileNameWithoutExtension(frames[i]), out int frameNumber))
+            {
+                finalFrames[frameNumber] = frames[i];
+            }
+        }
+        
         GlobalFFOptions.Configure(new FFOptions() { BinaryFolder = @"C:\ProgramData\chocolatey\lib\ffmpeg\tools\ffmpeg\bin" });
         
+        float duration = finalFrames.Length / (float)FrameRate;
+
+        if (RequiresPaletteGeneration())
+        {
+            GeneratePalette(finalFrames, framesPath);
+        }
+        
         return await FFMpegArguments
-            .FromConcatInput(frames)
-            .OutputToFile($"{framesPath}/output.mp4", true, options =>
+            .FromConcatInput(finalFrames, options =>
+            {
+                options.WithFramerate(FrameRate);
+            })
+            .AddFileInput(Path.Combine(framesPath, "palette.png"))
+            .OutputToFile(outputPath, true, options =>
             {
-                options.WithVideoCodec(VideoCodec.LibX264)
-                    .WithFramerate(frameRate)
-                    .ForcePixelFormat("yuv420p");
+                options.WithCustomArgument($"-filter_complex \"[0:v]fps={FrameRate},scale={Size.X}:{Size.Y}:flags=lanczos[x];[x][1:v]paletteuse\"") // Apply the palette
+                    .WithCustomArgument($"-vsync 0"); // Ensure each input frame gets displayed exactly once
             })
             .ProcessAsynchronously();
     }
+
+    private bool RequiresPaletteGeneration()
+    {
+        return OutputFormat == "gif";
+    }
+
+    private void GeneratePalette(string[] frames, string path)
+    {
+        string palettePath = Path.Combine(path, "palette.png");
+        FFMpegArguments
+            .FromConcatInput(frames, options =>
+            {
+                options.WithFramerate(FrameRate);
+            })
+            .OutputToFile(palettePath, true, options =>
+            {
+                options
+                    .WithCustomArgument($"-vf \"palettegen\"");
+            })
+            .ProcessSynchronously();
+    }
 }

+ 1 - 0
src/PixiEditor.AnimationRenderer.FFmpeg/PixiEditor.AnimationRenderer.FFmpeg.csproj

@@ -8,6 +8,7 @@
 
     <ItemGroup>
       <ProjectReference Include="..\PixiEditor.AnimationRenderer.Core\PixiEditor.AnimationRenderer.Core.csproj" />
+      <ProjectReference Include="..\PixiEditor.Numerics\PixiEditor.Numerics.csproj" />
     </ItemGroup>
 
     <ItemGroup>

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

@@ -603,4 +603,5 @@
   "GIF_FILE": "GIFs",
   "BMP_FILE": "BMP Images",
   "IMAGE_FILES": "Image Files",
+  "VIDEO_FILES": "Video Files",
 }

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

@@ -122,7 +122,6 @@ internal static class ServiceCollectionHelpers
             .AddSingleton<PaletteFileParser, PixiPaletteParser>()
             // Palette data sources
             .AddSingleton<PaletteListDataSource, LocalPalettesFetcher>()
-            .AddSingleton<IAnimationRenderer, FFMpegRenderer>()
             .AddMenuBuilders();
     }
 

+ 6 - 8
src/PixiEditor.AvaloniaUI/Helpers/SupportedFilesHelper.cs

@@ -62,25 +62,23 @@ internal class SupportedFilesHelper
         return fileData;
     }
 
-    public static List<IoFileType> GetAllSupportedFileTypes(bool includePixi)
+    public static List<IoFileType> GetAllSupportedFileTypes(FileTypeDialogDataSet.SetKind setKind)
     {
-        var allExts = FileTypes.ToList();
-        if (!includePixi)
-            allExts.RemoveAll(item => item is PixiFileType);
+        var allExts = FileTypes.Where(x => x.SetKind.HasFlag(setKind)).ToList();
         return allExts;
     }
 
-    public static List<FilePickerFileType> BuildSaveFilter(bool includePixi)
+    public static List<FilePickerFileType> BuildSaveFilter(FileTypeDialogDataSet.SetKind setKind = FileTypeDialogDataSet.SetKind.Any)
     {
-        var allSupportedExtensions = GetAllSupportedFileTypes(includePixi);
+        var allSupportedExtensions = GetAllSupportedFileTypes(setKind);
         var filter = allSupportedExtensions.Select(i => i.SaveFilter).ToList();
 
         return filter;
     }
 
-    public static IoFileType GetSaveFileType(bool includePixi, IStorageFile file)
+    public static IoFileType GetSaveFileType(FileTypeDialogDataSet.SetKind setKind, IStorageFile file)
     {
-        var allSupportedExtensions = GetAllSupportedFileTypes(includePixi);
+        var allSupportedExtensions = GetAllSupportedFileTypes(setKind);
 
         if (file is null)
             return null;

+ 0 - 1
src/PixiEditor.AvaloniaUI/Models/Files/BmpFileType.cs

@@ -10,6 +10,5 @@ internal class BmpFileType : ImageFileType
     public override string[] Extensions { get; } = new[] { ".bmp" };
     public override string DisplayName => new LocalizedString("BMP_FILE");
     public override EncodedImageFormat EncodedImageFormat { get; } = EncodedImageFormat.Bmp;
-
     public override SolidColorBrush EditorColor { get; } = new SolidColorBrush(new Color(255, 255, 140, 0));
 }

+ 1 - 4
src/PixiEditor.AvaloniaUI/Models/Files/GifFileType.cs

@@ -1,15 +1,12 @@
 using Avalonia.Media;
-using PixiEditor.DrawingApi.Core.Surface;
 using PixiEditor.Extensions.Common.Localization;
 
 namespace PixiEditor.AvaloniaUI.Models.Files;
 
-internal class GifFileType : ImageFileType
+internal class GifFileType : VideoFileType
 {
     public static GifFileType GifFile { get; } = new GifFileType();
     public override string[] Extensions { get; } = new[] { ".gif" };
     public override string DisplayName => new LocalizedString("GIF_FILE");
-    public override EncodedImageFormat EncodedImageFormat { get; } = EncodedImageFormat.Gif;
-    
     public override SolidColorBrush EditorColor { get; } = new SolidColorBrush(new Color(255, 180, 0, 255));
 }

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

@@ -12,8 +12,10 @@ namespace PixiEditor.AvaloniaUI.Models.Files;
 internal abstract class ImageFileType : IoFileType
 {
     public abstract EncodedImageFormat EncodedImageFormat { get; }
+    
+    public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Image;
 
-    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, VecI? exportSize = null)
+    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig exportConfig)
     {
         var maybeBitmap = document.TryRenderWholeImage();
         if (maybeBitmap.IsT0)
@@ -28,16 +30,17 @@ internal abstract class ImageFileType : IoFileType
         }
 
         UniversalFileEncoder encoder = new(mappedFormat);
-        return TrySaveAs(encoder, pathWithExtension, bitmap, exportSize);
+        return TrySaveAs(encoder, pathWithExtension, bitmap, exportConfig);
     }
     
     /// <summary>
     /// Saves image to PNG file. Messes with the passed bitmap.
     /// </summary>
-    private static SaveResult TrySaveAs(IFileEncoder encoder, string savePath, Surface bitmap, VecI? exportSize)
+    private static SaveResult TrySaveAs(IFileEncoder encoder, string savePath, Surface bitmap, ExportConfig config)
     {
         try
         {
+            VecI? exportSize = config.ExportSize;
             if (exportSize is not null && exportSize != bitmap.Size)
                 bitmap = bitmap.ResizeNearestNeighbor((VecI)exportSize);
 

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

@@ -24,6 +24,8 @@ internal abstract class IoFileType
     public abstract string DisplayName { get; }
 
     public virtual SolidColorBrush EditorColor { get; } = new SolidColorBrush(Color.FromRgb(100, 100, 100));
+    
+    public abstract FileTypeDialogDataSet.SetKind SetKind { get; }
 
     public FilePickerFileType SaveFilter
     {
@@ -43,5 +45,5 @@ internal abstract class IoFileType
         return "*" + extension;
     }
 
-    public abstract SaveResult TrySave(string pathWithExtension, DocumentViewModel document, VecI? exportSize = null);
+    public abstract SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config);
 }

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

@@ -14,7 +14,9 @@ internal class PixiFileType : IoFileType
 
     public override SolidColorBrush EditorColor { get;  } = new SolidColorBrush(new Color(255, 226, 1, 45));
 
-    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, VecI? exportSize = null)
+    public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Pixi;
+
+    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config)
     {
         try
         {

+ 27 - 0
src/PixiEditor.AvaloniaUI/Models/Files/VideoFileType.cs

@@ -0,0 +1,27 @@
+using PixiEditor.AvaloniaUI.Models.IO;
+using PixiEditor.AvaloniaUI.ViewModels.Document;
+
+namespace PixiEditor.AvaloniaUI.Models.Files;
+
+internal abstract class VideoFileType : IoFileType
+{
+    public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Video;
+    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config)
+    {
+        if (config.AnimationRenderer is null)
+            return SaveResult.UnknownError;
+        
+        document.RenderFrames(Paths.TempRenderingPath, surface =>
+        {
+            if (config.ExportSize is not null && config.ExportSize != surface.Size)
+            {
+                return surface.ResizeNearestNeighbor(config.ExportSize ?? surface.Size);
+            }
+
+            return surface;
+        });
+        
+        config.AnimationRenderer.RenderAsync(Paths.TempRenderingPath, pathWithExtension);
+        return SaveResult.Success;
+    }
+}

+ 12 - 0
src/PixiEditor.AvaloniaUI/Models/IO/ExportConfig.cs

@@ -0,0 +1,12 @@
+using PixiEditor.AnimationRenderer.Core;
+using PixiEditor.AnimationRenderer.FFmpeg;
+using PixiEditor.Numerics;
+
+namespace PixiEditor.AvaloniaUI.Models.IO;
+
+public class ExportConfig
+{
+   public static ExportConfig Empty { get; } = new ExportConfig();
+   public VecI? ExportSize { get; set; }
+   public IAnimationRenderer? AnimationRenderer { get; set; }
+}

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

@@ -50,7 +50,7 @@ internal class Exporter
     /// <summary>
     /// Attempts to save file using a SaveFileDialog
     /// </summary>
-    public static async Task<ExporterResult> TrySaveWithDialog(DocumentViewModel document, VecI? exportSize = null)
+    public static async Task<ExporterResult> TrySaveWithDialog(DocumentViewModel document, ExportConfig exportConfig)
     {
         ExporterResult result = new(DialogSaveResult.UnknownError, null);
 
@@ -58,7 +58,7 @@ internal class Exporter
         {
             var file = await desktop.MainWindow.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
             {
-                FileTypeChoices = SupportedFilesHelper.BuildSaveFilter(true), DefaultExtension = "pixi"
+                FileTypeChoices = SupportedFilesHelper.BuildSaveFilter(), DefaultExtension = "pixi"
             });
 
             if (file is null)
@@ -67,9 +67,9 @@ internal class Exporter
                 return result;
             }
 
-            var fileType = SupportedFilesHelper.GetSaveFileType(true, file);
+            var fileType = SupportedFilesHelper.GetSaveFileType(FileTypeDialogDataSet.SetKind.Any, file);
 
-            var saveResult = TrySaveUsingDataFromDialog(document, file.Path.LocalPath, fileType, out string fixedPath, exportSize);
+            var saveResult = TrySaveUsingDataFromDialog(document, file.Path.LocalPath, fileType, out string fixedPath, exportConfig);
             if (saveResult == SaveResult.Success)
             {
                 result.Path = fixedPath;
@@ -84,10 +84,10 @@ internal class Exporter
     /// <summary>
     /// Takes data as returned by SaveFileDialog and attempts to use it to save the document
     /// </summary>
-    public static SaveResult TrySaveUsingDataFromDialog(DocumentViewModel document, string pathFromDialog, IoFileType fileTypeFromDialog, out string finalPath, VecI? exportSize = null)
+    public static SaveResult TrySaveUsingDataFromDialog(DocumentViewModel document, string pathFromDialog, IoFileType fileTypeFromDialog, out string finalPath, ExportConfig exportConfig)
     {
         finalPath = SupportedFilesHelper.FixFileExtension(pathFromDialog, fileTypeFromDialog);
-        var saveResult = TrySave(document, finalPath, exportSize);
+        var saveResult = TrySave(document, finalPath, exportConfig);
         if (saveResult != SaveResult.Success)
             finalPath = "";
 
@@ -97,7 +97,7 @@ internal class Exporter
     /// <summary>
     /// Attempts to save the document into the given location, filetype is inferred from path
     /// </summary>
-    public static SaveResult TrySave(DocumentViewModel document, string pathWithExtension, VecI? exportSize = null)
+    public static SaveResult TrySave(DocumentViewModel document, string pathWithExtension, ExportConfig exportConfig)
     {
         string directory = Path.GetDirectoryName(pathWithExtension);
         if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
@@ -108,7 +108,7 @@ internal class Exporter
         if (typeFromPath is null)
             return SaveResult.UnknownError;
         
-        return typeFromPath.TrySave(pathWithExtension, document, exportSize);
+        return typeFromPath.TrySave(pathWithExtension, document, exportConfig);
     }
 
     public static void SaveAsGZippedBytes(string path, Surface surface)

+ 15 - 3
src/PixiEditor.AvaloniaUI/Models/IO/FileTypeDialogDataSet.cs

@@ -9,14 +9,22 @@ namespace PixiEditor.AvaloniaUI.Models.IO;
 
 internal class FileTypeDialogDataSet
 {
-    public enum SetKind { Any, Pixi, Images }
+    [Flags]
+    public enum SetKind
+    {
+        None = 0,
+        Pixi = 1 << 0,
+        Image = 1 << 1,
+        Video = 1 << 2,
+        Any = ~0
+    }
     IEnumerable<IoFileType> fileTypes;
     string displayName;
 
     public FileTypeDialogDataSet(SetKind kind, IEnumerable<IoFileType> fileTypes = null)
     {
         if (fileTypes == null)
-            fileTypes = SupportedFilesHelper.GetAllSupportedFileTypes(true);
+            fileTypes = SupportedFilesHelper.GetAllSupportedFileTypes(SetKind.Any);
         var allSupportedExtensions = fileTypes;
         if (kind == SetKind.Any)
         {
@@ -26,10 +34,14 @@ internal class FileTypeDialogDataSet
         {
             Init(new LocalizedString("PIXI_FILE"), new[] { PixiFileType.PixiFile });
         }
-        else if (kind == SetKind.Images)
+        else if (kind == SetKind.Image)
         {
             Init(new LocalizedString("IMAGE_FILES"), allSupportedExtensions, PixiFileType.PixiFile);
         }
+        else if (kind == SetKind.Video)
+        {
+            Init(new LocalizedString("VIDEO_FILES"), allSupportedExtensions);
+        }
     }
 
     public FileTypeDialogDataSet(string displayName, IEnumerable<IoFileType> fileTypes, IoFileType? fileTypeToSkip = null)

+ 3 - 3
src/PixiEditor.AvaloniaUI/Models/Rendering/AffectedAreasGatherer.cs

@@ -104,7 +104,7 @@ internal class AffectedAreasGatherer
                         AddWholeCanvasToImagePreviews(info.TargetLayerGuid);
                     }
                     break;
-                case ActiveFrame_ChangeInfo info:
+                case ActiveFrame_ChangeInfo:
                     AddWholeCanvasToMainImage();
                     AddWholeCanvasToEveryImagePreview();
                     break;
@@ -112,11 +112,11 @@ internal class AffectedAreasGatherer
                     AddWholeCanvasToMainImage();
                     AddWholeCanvasToEveryImagePreview();
                     break;
-                case DeleteKeyFrame_ChangeInfo info:
+                case DeleteKeyFrame_ChangeInfo:
                     AddWholeCanvasToMainImage();
                     AddWholeCanvasToEveryImagePreview();
                     break;
-                case KeyFrameVisibility_ChangeInfo info:
+                case KeyFrameVisibility_ChangeInfo:
                     AddWholeCanvasToMainImage();
                     AddWholeCanvasToEveryImagePreview();
                     break;

+ 14 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Document/AnimationDataViewModel.cs

@@ -1,6 +1,7 @@
 using System.Collections.ObjectModel;
 using System.Collections.Specialized;
 using CommunityToolkit.Mvvm.ComponentModel;
+using PixiEditor.AnimationRenderer.Core;
 using PixiEditor.AvaloniaUI.Models.DocumentModels;
 using PixiEditor.AvaloniaUI.Models.Handlers;
 using PixiEditor.ChangeableDocument.Actions.Generated;
@@ -11,6 +12,7 @@ namespace PixiEditor.AvaloniaUI.ViewModels.Document;
 internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
 {
     private int _activeFrameBindable;
+    private int _frameRate = 60;
     public DocumentViewModel Document { get; }
     protected DocumentInternalParts Internals { get; }
     public IReadOnlyCollection<IKeyFrameHandler> KeyFrames => keyFrames;
@@ -31,6 +33,18 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
         }
     }
 
+    public IAnimationRenderer Renderer { get; set; }
+
+    public int FrameRate
+    {
+        get => _frameRate;
+        set
+        {
+            _frameRate = value;
+            OnPropertyChanged(nameof(FrameRate));
+        }
+    } 
+
     public AnimationDataViewModel(DocumentViewModel document, DocumentInternalParts internals)
     {
         Document = document;

+ 15 - 5
src/PixiEditor.AvaloniaUI/ViewModels/Document/DocumentViewModel.cs

@@ -689,7 +689,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         }
     }
 
-    public Image[] RenderFrames()
+    public Image[] RenderFrames(Func<Surface, Surface> processFrameAction = null)
     {
         if (AnimationDataViewModel.KeyFrames.Count == 0)
             return[];
@@ -710,6 +710,11 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
                 continue;
             }
             
+            if (processFrameAction is not null)
+            {
+                surface = processFrameAction(surface.AsT1);
+            }
+            
             images[i] = surface.AsT1.DrawingSurface.Snapshot();
         }
 
@@ -717,10 +722,10 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         return images;
     }
 
-    public void RenderFrames(string tempRenderingPath)
+    public bool RenderFrames(string tempRenderingPath, Func<Surface, Surface> processFrameAction = null)
     {
         if (AnimationDataViewModel.KeyFrames.Count == 0)
-            return;
+            return false;
 
         if (!Directory.Exists(tempRenderingPath))
         {
@@ -743,15 +748,20 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
             var surface = TryRenderWholeImage();
             if (surface.IsT0)
             {
-                continue;
+                return false;
+            }
+            
+            if (processFrameAction is not null)
+            {
+                surface = processFrameAction(surface.AsT1);
             }
-
             using var stream = new FileStream(Path.Combine(tempRenderingPath, $"{i}.png"), FileMode.Create);
             surface.AsT1.DrawingSurface.Snapshot().Encode().SaveTo(stream);
             stream.Position = 0;
         }
         
         Internals.Tracker.ProcessActionsSync(new List<IAction> { new ActiveFrame_Action(activeFrame), new EndActiveFrame_Action() });
+        return true;
     }
 
     private static void ClearTempFolder(string tempRenderingPath)

+ 1 - 16
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/AnimationsViewModel.cs

@@ -12,22 +12,8 @@ namespace PixiEditor.AvaloniaUI.ViewModels.SubViewModels;
 [Command.Group("PixiEditor.Animations", "ANIMATIONS")]
 internal class AnimationsViewModel : SubViewModel<ViewModelMain>
 {
-    private IAnimationRenderer animationRenderer;
-    public AnimationsViewModel(ViewModelMain owner, IAnimationRenderer renderer) : base(owner)
+    public AnimationsViewModel(ViewModelMain owner) : base(owner)
     {
-        animationRenderer = renderer;
-    }
-    
-    [Command.Basic("PixiEditor.Animations.RenderAnimation", "Render Animation (MP4)", "Renders the animation as an MP4 file")]
-    public async Task RenderAnimation()
-    {
-        var document = Owner.DocumentManagerSubViewModel.ActiveDocument;
-        
-        if (document is null)
-            return;
-        
-        document.RenderFrames(Paths.TempRenderingPath);
-        await animationRenderer.RenderAsync(Paths.TempRenderingPath);
     }
     
     [Command.Basic("PixiEditor.Animation.CreateRasterKeyFrame", "Create Raster Key Frame", "Create a raster key frame", Parameter = false)]
@@ -110,7 +96,6 @@ internal class AnimationsViewModel : SubViewModel<ViewModelMain>
             int y = i / grid.columns;
             surface.DrawingSurface.Canvas.DrawImage(images[i], x * document.Width, y * document.Height);
         }
-        
         surface.SaveToDesktop();
     }
 

+ 28 - 14
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/FileViewModel.cs

@@ -107,7 +107,8 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         {
             OpenFromPath(file);
         }
-        else if ((Owner.DocumentManagerSubViewModel.Documents.Count == 0 && !args.Contains("--crash")) && !args.Contains("--openedInExisting"))
+        else if ((Owner.DocumentManagerSubViewModel.Documents.Count == 0 && !args.Contains("--crash")) &&
+                 !args.Contains("--openedInExisting"))
         {
             if (PixiEditorSettings.StartupWindow.ShowStartupWindow.Value)
             {
@@ -149,7 +150,8 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         }
     }
 
-    [Command.Basic("PixiEditor.File.OpenFileFromClipboard", "OPEN_FILE_FROM_CLIPBOARD", "OPEN_FILE_FROM_CLIPBOARD_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.HasImageInClipboard")]
+    [Command.Basic("PixiEditor.File.OpenFileFromClipboard", "OPEN_FILE_FROM_CLIPBOARD",
+        "OPEN_FILE_FROM_CLIPBOARD_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.HasImageInClipboard")]
     public async Task OpenFromClipboard()
     {
         var images = await ClipboardController.GetImagesFromClipboard();
@@ -170,12 +172,14 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
     {
         foreach (DocumentViewModel document in Owner.DocumentManagerSubViewModel.Documents)
         {
-            if (document.FullFilePath is not null && System.IO.Path.GetFullPath(document.FullFilePath) == System.IO.Path.GetFullPath(path))
+            if (document.FullFilePath is not null &&
+                System.IO.Path.GetFullPath(document.FullFilePath) == System.IO.Path.GetFullPath(path))
             {
                 Owner.WindowSubViewModel.MakeDocumentViewportActive(document);
                 return true;
             }
         }
+
         return false;
     }
 
@@ -273,11 +277,13 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         AddRecentlyOpened(path);
     }
 
-    [Command.Basic("PixiEditor.File.New", "NEW_IMAGE", "CREATE_NEW_IMAGE", Key = Key.N, Modifiers = KeyModifiers.Control,
+    [Command.Basic("PixiEditor.File.New", "NEW_IMAGE", "CREATE_NEW_IMAGE", Key = Key.N,
+        Modifiers = KeyModifiers.Control,
         MenuItemPath = "FILE/NEW_FILE", MenuItemOrder = 0, Icon = PixiPerfectIcons.File)]
     public async Task CreateFromNewFileDialog()
     {
-        Window mainWindow = (Application.Current.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime).MainWindow;
+        Window mainWindow = (Application.Current.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)
+            .MainWindow;
         NewFileDialog newFile = new NewFileDialog(mainWindow);
         if (await newFile.ShowDialog())
         {
@@ -303,9 +309,11 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         Owner.WindowSubViewModel.MakeDocumentViewportActive(doc);
     }
 
-    [Command.Basic("PixiEditor.File.Save", false, "SAVE", "SAVE_IMAGE", CanExecute = "PixiEditor.HasDocument", Key = Key.S, Modifiers = KeyModifiers.Control, Icon = PixiPerfectIcons.Save,
+    [Command.Basic("PixiEditor.File.Save", false, "SAVE", "SAVE_IMAGE", CanExecute = "PixiEditor.HasDocument",
+        Key = Key.S, Modifiers = KeyModifiers.Control, Icon = PixiPerfectIcons.Save,
         MenuItemPath = "FILE/SAVE_PIXI", MenuItemOrder = 3)]
-    [Command.Basic("PixiEditor.File.SaveAsNew", true, "SAVE_AS", "SAVE_IMAGE_AS", CanExecute = "PixiEditor.HasDocument", Key = Key.S, Modifiers = KeyModifiers.Control | KeyModifiers.Shift, Icon = PixiPerfectIcons.Save,
+    [Command.Basic("PixiEditor.File.SaveAsNew", true, "SAVE_AS", "SAVE_IMAGE_AS", CanExecute = "PixiEditor.HasDocument",
+        Key = Key.S, Modifiers = KeyModifiers.Control | KeyModifiers.Shift, Icon = PixiPerfectIcons.Save,
         MenuItemPath = "FILE/SAVE_AS_PIXI", MenuItemOrder = 4)]
     public async Task<bool> SaveActiveDocument(bool asNew)
     {
@@ -320,7 +328,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         string finalPath = null;
         if (asNew || string.IsNullOrEmpty(document.FullFilePath))
         {
-            var result = await Exporter.TrySaveWithDialog(document);
+            var result = await Exporter.TrySaveWithDialog(document, ExportConfig.Empty);
             if (result.Result == DialogSaveResult.Cancelled)
                 return false;
             if (result.Result != DialogSaveResult.Success)
@@ -334,12 +342,13 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         }
         else
         {
-            var result = Exporter.TrySave(document, document.FullFilePath);
+            var result = Exporter.TrySave(document, document.FullFilePath, ExportConfig.Empty);
             if (result != SaveResult.Success)
             {
                 ShowSaveError((DialogSaveResult)result);
                 return false;
             }
+
             finalPath = document.FullFilePath;
         }
 
@@ -352,7 +361,8 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
     ///     Generates export dialog or saves directly if save data is known.
     /// </summary>
     /// <param name="parameter">CommandProperty.</param>
-    [Command.Basic("PixiEditor.File.Export", "EXPORT", "EXPORT_IMAGE", CanExecute = "PixiEditor.HasDocument", Key = Key.E, Modifiers = KeyModifiers.Control,
+    [Command.Basic("PixiEditor.File.Export", "EXPORT", "EXPORT_IMAGE", CanExecute = "PixiEditor.HasDocument",
+        Key = Key.E, Modifiers = KeyModifiers.Control,
         MenuItemPath = "FILE/EXPORT_IMG", MenuItemOrder = 5, Icon = PixiPerfectIcons.Image)]
     public async Task ExportFile()
     {
@@ -362,11 +372,14 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
             if (doc is null)
                 return;
 
-            ExportFileDialog info = new ExportFileDialog(MainWindow.Current, doc.SizeBindable, doc) 
-                { SuggestedName = Path.GetFileNameWithoutExtension(doc.FileName) };
+            ExportFileDialog info = new ExportFileDialog(MainWindow.Current, doc.SizeBindable, doc)
+            {
+                SuggestedName = Path.GetFileNameWithoutExtension(doc.FileName)
+            };
             if (await info.ShowDialog())
             {
-                SaveResult result = Exporter.TrySaveUsingDataFromDialog(doc, info.FilePath, info.ChosenFormat, out string finalPath, new(info.FileWidth, info.FileHeight));
+                SaveResult result = Exporter.TrySaveUsingDataFromDialog(doc, info.FilePath, info.ChosenFormat,
+                    out string finalPath, info.ExportConfig);
                 if (result == SaveResult.Success)
                     IOperatingSystem.Current.OpenFolder(finalPath);
                 else
@@ -408,7 +421,8 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
             return;
         }
 
-        List<RecentlyOpenedDocument> recentlyOpenedDocuments = new List<RecentlyOpenedDocument>(RecentlyOpened.Take(newAmount));
+        List<RecentlyOpenedDocument> recentlyOpenedDocuments =
+            new List<RecentlyOpenedDocument>(RecentlyOpened.Take(newAmount));
 
         RecentlyOpened.Clear();
 

+ 1 - 1
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/LayersViewModel.cs

@@ -381,7 +381,7 @@ internal class LayersViewModel : SubViewModel<ViewModelMain>
 
     private async Task<string> OpenReferenceLayerFilePicker()
     {
-        var imagesFilter = new FileTypeDialogDataSet(FileTypeDialogDataSet.SetKind.Images).GetFormattedTypes(true);
+        var imagesFilter = new FileTypeDialogDataSet(FileTypeDialogDataSet.SetKind.Image).GetFormattedTypes(true);
         if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
         {
             var filePicker = await desktop.MainWindow.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions()

+ 13 - 0
src/PixiEditor.AvaloniaUI/Views/Dialogs/ExportFileDialog.cs

@@ -1,7 +1,9 @@
 using System.Threading.Tasks;
 using Avalonia.Controls;
+using PixiEditor.AnimationRenderer.FFmpeg;
 using PixiEditor.AvaloniaUI.Models.Dialogs;
 using PixiEditor.AvaloniaUI.Models.Files;
+using PixiEditor.AvaloniaUI.Models.IO;
 using PixiEditor.AvaloniaUI.ViewModels.Document;
 using PixiEditor.DrawingApi.Core.Numerics;
 using PixiEditor.Numerics;
@@ -21,6 +23,8 @@ internal class ExportFileDialog : CustomDialog
     private string suggestedName;
     
     private DocumentViewModel document;
+    
+    public ExportConfig ExportConfig { get; set; } = new ExportConfig();
 
     public ExportFileDialog(Window owner, VecI size, DocumentViewModel doc) : base(owner)
     {
@@ -88,6 +92,7 @@ internal class ExportFileDialog : CustomDialog
             }
         }
     }
+    
     public override async Task<bool> ShowDialog()
     {
         ExportFilePopup popup = new ExportFilePopup(FileWidth, FileHeight, document) { SuggestedName = SuggestedName };
@@ -99,6 +104,14 @@ internal class ExportFileDialog : CustomDialog
             FileHeight = popup.SaveHeight;
             FilePath = popup.SavePath;
             ChosenFormat = popup.SaveFormat;
+            
+            ExportConfig.ExportSize = new VecI(FileWidth, FileHeight);
+            ExportConfig.AnimationRenderer = ChosenFormat is VideoFileType ? new FFMpegRenderer()
+            {
+                Size = new VecI(FileWidth, FileHeight),
+                OutputFormat = ChosenFormat.PrimaryExtension.Replace(".", ""),
+                FrameRate = 60
+            } : null;
         }
 
         return result;

+ 13 - 9
src/PixiEditor.AvaloniaUI/Views/Dialogs/ExportFilePopup.axaml

@@ -14,15 +14,19 @@
     <DockPanel Background="{DynamicResource ThemeBackgroundBrush}">
         <Button DockPanel.Dock="Bottom" HorizontalAlignment="Center" IsDefault="True"
                 ui1:Translator.Key="EXPORT" Command="{Binding ExportCommand, ElementName=saveFilePopup}" />
-
         <StackPanel HorizontalAlignment="Center" VerticalAlignment="Stretch"  Orientation="Vertical"
                     Margin="0,15,0,0">
-            <StackPanel Spacing="5" Orientation="Horizontal" HorizontalAlignment="Center">
-                <RadioButton GroupName="ExportType" Content="Image" IsChecked="True" IsDefault="True"/>
-                <RadioButton GroupName="ExportType" Content="Animation" />
-                <RadioButton GroupName="ExportType" Content="SpriteSheet" />
-            </StackPanel>
-
+            <TabControl SelectedIndex="{Binding SelectedExportIndex, ElementName=saveFilePopup}">
+                <TabControl.Items>
+                    <TabItem IsSelected="True" ui1:Translator.Key="EXPORT_IMAGE_HEADER">
+                        
+                    </TabItem>
+                    <TabItem ui1:Translator.Key="EXPORT_ANIMATION_HEADER">
+                        
+                    </TabItem>
+                    <TabItem ui1:Translator.Key="EXPORT_SPRITESHEET_HEADER"/>
+                </TabControl.Items>
+            </TabControl>
             <Border Margin="15, 30" Padding="10"
                     Background="{DynamicResource ThemeBackgroundBrush1}"
                     CornerRadius="{DynamicResource ControlCornerRadius}">
@@ -33,10 +37,10 @@
                     </Grid.ColumnDefinitions>
                     <Grid>
                         <Grid.RowDefinitions>
-                            <RowDefinition />
+                            <RowDefinition Height="Auto"/>
                             <RowDefinition Height="Auto" />
                         </Grid.RowDefinitions>
-                        <input:SizePicker
+                        <input:SizePicker Grid.Row="0"
                             x:Name="sizePicker"
                             IsSizeUnitSelectionVisible="True"
                             VerticalAlignment="Top"

+ 94 - 32
src/PixiEditor.AvaloniaUI/Views/Dialogs/ExportFilePopup.axaml.cs

@@ -1,11 +1,14 @@
 using System.Threading.Tasks;
 using Avalonia;
 using Avalonia.Platform.Storage;
+using Avalonia.Threading;
 using ChunkyImageLib;
 using CommunityToolkit.Mvvm.Input;
 using PixiEditor.AvaloniaUI.Helpers;
 using PixiEditor.AvaloniaUI.Models.Files;
+using PixiEditor.AvaloniaUI.Models.IO;
 using PixiEditor.AvaloniaUI.ViewModels.Document;
+using PixiEditor.DrawingApi.Core.Surface.ImageData;
 using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Numerics;
 
@@ -13,6 +16,43 @@ namespace PixiEditor.AvaloniaUI.Views.Dialogs;
 
 internal partial class ExportFilePopup : PixiEditorPopup
 {
+    public static readonly StyledProperty<int> SaveHeightProperty =
+        AvaloniaProperty.Register<ExportFilePopup, int>(nameof(SaveHeight), 32);
+
+    public static readonly StyledProperty<int> SaveWidthProperty =
+        AvaloniaProperty.Register<ExportFilePopup, int>(nameof(SaveWidth), 32);
+
+    public static readonly StyledProperty<RelayCommand> SetBestPercentageCommandProperty =
+        AvaloniaProperty.Register<ExportFilePopup, RelayCommand>(nameof(SetBestPercentageCommand));
+
+    public static readonly StyledProperty<string?> SavePathProperty =
+        AvaloniaProperty.Register<ExportFilePopup, string?>(nameof(SavePath), "");
+
+    public static readonly StyledProperty<IoFileType> SaveFormatProperty =
+        AvaloniaProperty.Register<ExportFilePopup, IoFileType>(nameof(SaveFormat), new PngFileType());
+
+    public static readonly StyledProperty<AsyncRelayCommand> ExportCommandProperty =
+        AvaloniaProperty.Register<ExportFilePopup, AsyncRelayCommand>(
+            nameof(ExportCommand));
+
+    public static readonly StyledProperty<string> SuggestedNameProperty =
+        AvaloniaProperty.Register<ExportFilePopup, string>(
+            nameof(SuggestedName));
+
+    public static readonly StyledProperty<Surface> ExportPreviewProperty =
+        AvaloniaProperty.Register<ExportFilePopup, Surface>(
+            nameof(ExportPreview));
+
+    public static readonly StyledProperty<int> SelectedExportIndexProperty =
+        AvaloniaProperty.Register<ExportFilePopup, int>(
+            nameof(SelectedExportIndex), 0);
+
+    public int SelectedExportIndex
+    {
+        get => GetValue(SelectedExportIndexProperty);
+        set => SetValue(SelectedExportIndexProperty, value);
+    }
+
     public int SaveWidth
     {
         get => (int)GetValue(SaveWidthProperty);
@@ -38,31 +78,6 @@ internal partial class ExportFilePopup : PixiEditorPopup
         set => SetValue(SaveFormatProperty, value);
     }
 
-    public static readonly StyledProperty<int> SaveHeightProperty =
-        AvaloniaProperty.Register<ExportFilePopup, int>(nameof(SaveHeight), 32);
-
-    public static readonly StyledProperty<int> SaveWidthProperty =
-        AvaloniaProperty.Register<ExportFilePopup, int>(nameof(SaveWidth), 32);
-
-    public static readonly StyledProperty<RelayCommand> SetBestPercentageCommandProperty =
-        AvaloniaProperty.Register<ExportFilePopup, RelayCommand>(nameof(SetBestPercentageCommand));
-
-    public static readonly StyledProperty<string?> SavePathProperty =
-        AvaloniaProperty.Register<ExportFilePopup, string?>(nameof(SavePath), "");
-
-    public static readonly StyledProperty<IoFileType> SaveFormatProperty =
-        AvaloniaProperty.Register<ExportFilePopup, IoFileType>(nameof(SaveFormat), new PngFileType());
-
-    public static readonly StyledProperty<AsyncRelayCommand> ExportCommandProperty =
-        AvaloniaProperty.Register<ExportFilePopup, AsyncRelayCommand>(
-            nameof(ExportCommand));
-
-    public static readonly StyledProperty<string> SuggestedNameProperty = AvaloniaProperty.Register<ExportFilePopup, string>(
-        nameof(SuggestedName));
-
-    public static readonly StyledProperty<Surface> ExportPreviewProperty = AvaloniaProperty.Register<ExportFilePopup, Surface>(
-        nameof(ExportPreview));
-
     public Surface ExportPreview
     {
         get => GetValue(ExportPreviewProperty);
@@ -87,14 +102,19 @@ internal partial class ExportFilePopup : PixiEditorPopup
         set => SetValue(SetBestPercentageCommandProperty, value);
     }
 
+    public bool IsVideoExport => SelectedExportIndex == 1;
     public string SizeHint => new LocalizedString("EXPORT_SIZE_HINT", GetBestPercentage());
-    
+
     private DocumentViewModel document;
+    private Image[] videoPreviewFrames = [];
+    private DispatcherTimer videoPreviewTimer = new DispatcherTimer();
+    private int activeFrame = 0;
 
     static ExportFilePopup()
     {
         SaveWidthProperty.Changed.Subscribe(RerenderPreview);
         SaveHeightProperty.Changed.Subscribe(RerenderPreview);
+        SelectedExportIndexProperty.Changed.Subscribe(RerenderPreview);
     }
 
     public ExportFilePopup(int imageWidth, int imageHeight, DocumentViewModel document)
@@ -112,16 +132,50 @@ internal partial class ExportFilePopup : PixiEditorPopup
         SetBestPercentageCommand = new RelayCommand(SetBestPercentage);
         ExportCommand = new AsyncRelayCommand(Export);
         this.document = document;
+
         RenderPreview();
     }
-    
+
     private void RenderPreview()
     {
         if (document == null)
         {
             return;
         }
-        
+
+        videoPreviewTimer.Stop();
+        if (IsVideoExport)
+        {
+            videoPreviewFrames = document.RenderFrames(surface =>
+            {
+                if (SaveWidth != surface.Size.X || SaveHeight != surface.Size.Y)
+                {
+                    return surface.ResizeNearestNeighbor(new VecI(SaveWidth, SaveHeight));
+                }
+
+                return surface;
+            });
+            videoPreviewTimer = new DispatcherTimer(DispatcherPriority.Normal)
+            {
+                Interval = TimeSpan.FromMilliseconds(1000f / document.AnimationDataViewModel.FrameRate)
+            };
+            videoPreviewTimer.Tick += (_, _) =>
+            {
+                if (videoPreviewFrames.Length > 0)
+                {
+                    ExportPreview.DrawingSurface.Canvas.Clear();
+                    ExportPreview.DrawingSurface.Canvas.DrawImage(videoPreviewFrames[activeFrame], 0, 0);
+                    activeFrame = (activeFrame + 1) % videoPreviewFrames.Length;
+                }
+                else
+                {
+                    videoPreviewTimer.Stop();
+                }
+            };
+            
+            videoPreviewTimer.Start();
+        }
+
         var rendered = document.TryRenderWholeImage();
         if (rendered.IsT1)
         {
@@ -147,8 +201,12 @@ internal partial class ExportFilePopup : PixiEditorPopup
         {
             Title = new LocalizedString("EXPORT_SAVE_TITLE"),
             SuggestedFileName = SuggestedName,
-            SuggestedStartLocation = await GetTopLevel(this).StorageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents),
-            FileTypeChoices = SupportedFilesHelper.BuildSaveFilter(false),
+            SuggestedStartLocation =
+                await GetTopLevel(this).StorageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents),
+            FileTypeChoices =
+                SupportedFilesHelper.BuildSaveFilter(SelectedExportIndex == 1
+                    ? FileTypeDialogDataSet.SetKind.Video
+                    : FileTypeDialogDataSet.SetKind.Image),
             ShowOverwritePrompt = true
         };
 
@@ -157,7 +215,10 @@ internal partial class ExportFilePopup : PixiEditorPopup
         {
             if (string.IsNullOrEmpty(file.Name) == false)
             {
-                SaveFormat = SupportedFilesHelper.GetSaveFileType(false, file);
+                SaveFormat = SupportedFilesHelper.GetSaveFileType(
+                    SelectedExportIndex == 1
+                        ? FileTypeDialogDataSet.SetKind.Video
+                        : FileTypeDialogDataSet.SetKind.Image, file);
                 if (SaveFormat == null)
                 {
                     return null;
@@ -168,6 +229,7 @@ internal partial class ExportFilePopup : PixiEditorPopup
                 return fileName;
             }
         }
+
         return null;
     }
 
@@ -189,7 +251,7 @@ internal partial class ExportFilePopup : PixiEditorPopup
         sizePicker.PercentageRb.IsChecked = true;
         sizePicker.PercentageLostFocus();
     }
-    
+
     private static void RerenderPreview(AvaloniaPropertyChangedEventArgs e)
     {
         if (e.Sender is ExportFilePopup popup)