diff --git a/src/PixiEditor.AnimationRenderer.Core/IAnimationRenderer.cs b/src/PixiEditor.AnimationRenderer.Core/IAnimationRenderer.cs index 3abcf0590..fd76144ff 100644 --- a/src/PixiEditor.AnimationRenderer.Core/IAnimationRenderer.cs +++ b/src/PixiEditor.AnimationRenderer.Core/IAnimationRenderer.cs @@ -7,3 +7,12 @@ public interface IAnimationRenderer public Task RenderAsync(List imageStream, string outputPath, CancellationToken cancellationToken, Action? progressCallback); public bool Render(List imageStream, string outputPath, CancellationToken cancellationToken, Action? progressCallback); } + +public enum QualityPreset +{ + VeryLow = 0, + Low = 1, + Medium = 2, + High = 3, + VeryHigh = 4, +} diff --git a/src/PixiEditor.AnimationRenderer.FFmpeg/FFMpegRenderer.cs b/src/PixiEditor.AnimationRenderer.FFmpeg/FFMpegRenderer.cs index a808c9826..9d611f148 100644 --- a/src/PixiEditor.AnimationRenderer.FFmpeg/FFMpegRenderer.cs +++ b/src/PixiEditor.AnimationRenderer.FFmpeg/FFMpegRenderer.cs @@ -17,6 +17,7 @@ public class FFMpegRenderer : IAnimationRenderer public int FrameRate { get; set; } = 60; public string OutputFormat { get; set; } = "mp4"; public VecI Size { get; set; } + public QualityPreset QualityPreset { get; set; } = QualityPreset.VeryHigh; public async Task RenderAsync(List rawFrames, string outputPath, CancellationToken cancellationToken, Action? progressCallback = null) @@ -215,11 +216,20 @@ public class FFMpegRenderer : IAnimationRenderer private FFMpegArgumentProcessor GetMp4Arguments(FFMpegArguments args, string outputPath) { + int qscale = QualityPreset switch + { + QualityPreset.VeryLow => 31, + QualityPreset.Low => 25, + QualityPreset.Medium => 19, + QualityPreset.High => 10, + QualityPreset.VeryHigh => 1, + _ => 2 + }; return args .OutputToFile(outputPath, true, options => { options.WithFramerate(FrameRate) - .WithVideoBitrate(1800) + .WithCustomArgument($"-qscale:v {qscale}") .WithVideoCodec("mpeg4") .ForcePixelFormat("yuv420p"); }); diff --git a/src/PixiEditor/Data/Localization/Languages/en.json b/src/PixiEditor/Data/Localization/Languages/en.json index 4810009dc..0f1f07601 100644 --- a/src/PixiEditor/Data/Localization/Languages/en.json +++ b/src/PixiEditor/Data/Localization/Languages/en.json @@ -1059,5 +1059,12 @@ "STEP_START": "Step back to closest cel", "STEP_END": "Step forward to closest cel", "STEP_FORWARD": "Step forward one frame", - "STEP_BACK": "Step back one frame" + "STEP_BACK": "Step back one frame", + "ANIMATION_QUALITY_PRESET": "Quality Preset", + "VERY_LOW_QUALITY_PRESET": "Very Low", + "LOW_QUALITY_PRESET": "Low", + "MEDIUM_QUALITY_PRESET": "Medium", + "HIGH_QUALITY_PRESET": "High", + "VERY_HIGH_QUALITY_PRESET": "Very High", + "EXPORT_FRAMES": "Export Frames" } \ No newline at end of file diff --git a/src/PixiEditor/Models/IO/ExportConfig.cs b/src/PixiEditor/Models/IO/ExportConfig.cs index c981b78ed..1a0f3603a 100644 --- a/src/PixiEditor/Models/IO/ExportConfig.cs +++ b/src/PixiEditor/Models/IO/ExportConfig.cs @@ -14,6 +14,7 @@ public class ExportConfig public VectorExportConfig? VectorExportConfig { get; set; } public string ExportOutput { get; set; } + public bool ExportFramesToFolder { get; set; } public ExportConfig(VecI exportSize) { diff --git a/src/PixiEditor/Models/IO/Exporter.cs b/src/PixiEditor/Models/IO/Exporter.cs index 58fb6d755..db63544d6 100644 --- a/src/PixiEditor/Models/IO/Exporter.cs +++ b/src/PixiEditor/Models/IO/Exporter.cs @@ -5,10 +5,12 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Platform.Storage; using ChunkyImageLib; using Drawie.Backend.Core; +using Drawie.Backend.Core.Surfaces; using Drawie.Backend.Core.Surfaces.ImageData; using PixiEditor.Helpers; using PixiEditor.Models.Files; using Drawie.Numerics; +using PixiEditor.UI.Common.Localization; using PixiEditor.ViewModels.Document; namespace PixiEditor.Models.IO; @@ -114,6 +116,23 @@ internal class Exporter if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) return new SaveResult(SaveResultType.InvalidPath); + if (exportConfig.ExportFramesToFolder) + { + try + { + await ExportFramesToFolderAsync(document, directory, exportConfig, job); + job?.Finish(); + return new SaveResult(SaveResultType.Success); + } + catch (Exception e) + { + job?.Finish(); + Console.WriteLine(e); + CrashHelper.SendExceptionInfo(e); + return new SaveResult(SaveResultType.UnknownError); + } + } + var typeFromPath = SupportedFilesHelper.ParseImageFormat(Path.GetExtension(pathWithExtension)); if (typeFromPath is null) @@ -161,6 +180,41 @@ internal class Exporter } } + private static async Task ExportFramesToFolderAsync(DocumentViewModel document, string directory, + ExportConfig exportConfig, ExportJob? job) + { + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + int totalFrames = document.AnimationDataViewModel.GetVisibleFramesCount(); + document.RenderFramesProgressive( + (surface, frame) => + { + job?.CancellationTokenSource.Token.ThrowIfCancellationRequested(); + job?.Report(((double)frame / totalFrames), + new LocalizedString("RENDERING_FRAME", frame, totalFrames)); + if (exportConfig.ExportSize != surface.Size) + { + var resized = surface.ResizeNearestNeighbor(exportConfig.ExportSize); + SaveAsPng(Path.Combine(directory, $"{frame}.png"), resized); + } + else + { + SaveAsPng(Path.Combine(directory, $"{frame}.png"), surface); + } + + }, CancellationToken.None, exportConfig.ExportOutput); + } + + public static void SaveAsPng(string path, Surface surface) + { + using var snapshot = surface.DrawingSurface.Snapshot(); + using var fileStream = new FileStream(path, FileMode.Create); + snapshot.Encode(EncodedImageFormat.Png).SaveTo(fileStream); + } + public static void SaveAsGZippedBytes(string path, Surface surface) { SaveAsGZippedBytes(path, surface, new RectI(VecI.Zero, surface.Size)); diff --git a/src/PixiEditor/Views/Dialogs/ExportFileDialog.cs b/src/PixiEditor/Views/Dialogs/ExportFileDialog.cs index 31ac28b7e..61c10c860 100644 --- a/src/PixiEditor/Views/Dialogs/ExportFileDialog.cs +++ b/src/PixiEditor/Views/Dialogs/ExportFileDialog.cs @@ -7,6 +7,7 @@ using PixiEditor.Models.Dialogs; using PixiEditor.Models.Files; using PixiEditor.Models.IO; using Drawie.Numerics; +using PixiEditor.AnimationRenderer.Core; using PixiEditor.ViewModels.Document; namespace PixiEditor.Views.Dialogs; @@ -143,14 +144,16 @@ internal class ExportFileDialog : CustomDialog FilePath = popup.SavePath; ChosenFormat = popup.SaveFormat; ExportOutput = popup.ExportOutput; - + ExportConfig.ExportSize = new VecI(FileWidth, FileHeight); ExportConfig.ExportOutput = ExportOutput.Name; + ExportConfig.ExportFramesToFolder = popup.FolderExport; ExportConfig.AnimationRenderer = ChosenFormat is VideoFileType ? new FFMpegRenderer() { Size = new VecI(FileWidth, FileHeight), OutputFormat = ChosenFormat.PrimaryExtension.Replace(".", ""), - FrameRate = document.AnimationDataViewModel.FrameRateBindable + FrameRate = document.AnimationDataViewModel.FrameRateBindable, + QualityPreset = (QualityPreset)popup.AnimationPresetIndex } : null; ExportConfig.ExportAsSpriteSheet = popup.IsSpriteSheetExport; diff --git a/src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml b/src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml index b98f3267d..aeed4a5c4 100644 --- a/src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml +++ b/src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml @@ -7,6 +7,7 @@ xmlns:indicators="clr-namespace:PixiEditor.Views.Indicators" xmlns:input1="clr-namespace:PixiEditor.Views.Input" xmlns:controls="clr-namespace:PixiEditor.UI.Common.Controls;assembly=PixiEditor.UI.Common" + xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters" CanResize="False" CanMinimize="False" SizeToContent="WidthAndHeight" @@ -27,7 +28,26 @@ - + + + + + + + + + + + + + + + + diff --git a/src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml.cs b/src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml.cs index 1c63e2001..92474407b 100644 --- a/src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml.cs +++ b/src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml.cs @@ -13,6 +13,7 @@ using PixiEditor.Helpers; using PixiEditor.Models.Files; using PixiEditor.Models.IO; using Drawie.Numerics; +using PixiEditor.AnimationRenderer.Core; using PixiEditor.UI.Common.Localization; using PixiEditor.ViewModels.Document; using Image = Drawie.Backend.Core.Surfaces.ImageData.Image; @@ -76,6 +77,14 @@ internal partial class ExportFilePopup : PixiEditorPopup public static readonly StyledProperty SizeHintProperty = AvaloniaProperty.Register( nameof(SizeHint)); + public static readonly StyledProperty FolderExportProperty = AvaloniaProperty.Register( + nameof(FolderExport)); + + public bool FolderExport + { + get => GetValue(FolderExportProperty); + set => SetValue(FolderExportProperty, value); + } public string SizeHint { get => GetValue(SizeHintProperty); @@ -171,6 +180,14 @@ internal partial class ExportFilePopup : PixiEditorPopup public bool IsSpriteSheetExport => SelectedExportIndex == 2; + public int AnimationPresetIndex + { + get { return (int)GetValue(AnimationPresetIndexProperty); } + set { SetValue(AnimationPresetIndexProperty, value); } + } + + public Array QualityPresetValues { get; } + private DocumentViewModel document; private Image[]? videoPreviewFrames = []; private DispatcherTimer videoPreviewTimer = new DispatcherTimer(); @@ -179,6 +196,9 @@ internal partial class ExportFilePopup : PixiEditorPopup private Task? generateSpriteSheetTask; + public static readonly StyledProperty AnimationPresetIndexProperty + = AvaloniaProperty.Register("AnimationPresetIndex", 4); + static ExportFilePopup() { SaveWidthProperty.Changed.Subscribe(RerenderPreview); @@ -193,8 +213,10 @@ internal partial class ExportFilePopup : PixiEditorPopup { SaveWidth = imageWidth; SaveHeight = imageHeight; + QualityPresetValues = Enum.GetValues(typeof(QualityPreset)); InitializeComponent(); + DataContext = this; Loaded += (_, _) => sizePicker.FocusWidthPicker(); @@ -467,37 +489,64 @@ internal partial class ExportFilePopup : PixiEditorPopup /// private async Task ChoosePath() { - FilePickerSaveOptions options = new FilePickerSaveOptions - { - 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), - FileTypeChoices = - SupportedFilesHelper.BuildSaveFilter(SelectedExportIndex == 1 - ? FileTypeDialogDataSet.SetKind.Video - : FileTypeDialogDataSet.SetKind.Image | FileTypeDialogDataSet.SetKind.Vector), - ShowOverwritePrompt = true - }; + bool folderExport = FolderExport && SelectedExportIndex == 1; - IStorageFile file = await GetTopLevel(this).StorageProvider.SaveFilePickerAsync(options); - if (file != null) + if (folderExport) { - if (string.IsNullOrEmpty(file.Name) == false) + FolderPickerOpenOptions options = new FolderPickerOpenOptions() { - SaveFormat = SupportedFilesHelper.GetSaveFileType( - SelectedExportIndex == 1 - ? FileTypeDialogDataSet.SetKind.Video - : FileTypeDialogDataSet.SetKind.Image | FileTypeDialogDataSet.SetKind.Vector, file); - if (SaveFormat == null) + Title = new LocalizedString("EXPORT_SAVE_TITLE"), + SuggestedStartLocation = string.IsNullOrEmpty(document.FullFilePath) + ? await GetTopLevel(this).StorageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents) + : await GetTopLevel(this).StorageProvider.TryGetFolderFromPathAsync(document.FullFilePath), + AllowMultiple = false, + }; + + var folders = await GetTopLevel(this).StorageProvider.OpenFolderPickerAsync(options); + if (folders.Count > 0) + { + IStorageFolder folder = folders[0]; + if (folder != null) { - return null; + SavePath = folder.Path.LocalPath; + return SavePath; } + } + } + else + { + FilePickerSaveOptions options = new FilePickerSaveOptions + { + 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), + FileTypeChoices = + SupportedFilesHelper.BuildSaveFilter(SelectedExportIndex == 1 + ? FileTypeDialogDataSet.SetKind.Video + : FileTypeDialogDataSet.SetKind.Image | FileTypeDialogDataSet.SetKind.Vector), + ShowOverwritePrompt = true + }; - string fileName = SupportedFilesHelper.FixFileExtension(file.Path.LocalPath, SaveFormat); + IStorageFile file = await GetTopLevel(this).StorageProvider.SaveFilePickerAsync(options); + if (file != null) + { + if (string.IsNullOrEmpty(file.Name) == false) + { + SaveFormat = SupportedFilesHelper.GetSaveFileType( + SelectedExportIndex == 1 + ? FileTypeDialogDataSet.SetKind.Video + : FileTypeDialogDataSet.SetKind.Image | FileTypeDialogDataSet.SetKind.Vector, file); + if (SaveFormat == null) + { + return null; + } - return fileName; + string fileName = SupportedFilesHelper.FixFileExtension(file.Path.LocalPath, SaveFormat); + + return fileName; + } } }