Merge branch 'master' into gradient-fixes

This commit is contained in:
Krzysztof Krysiński 2025-06-12 15:38:29 +02:00 committed by GitHub
commit 187be9274e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 182 additions and 29 deletions

View File

@ -7,3 +7,12 @@ public interface IAnimationRenderer
public Task<bool> RenderAsync(List<Image> imageStream, string outputPath, CancellationToken cancellationToken, Action<double>? progressCallback);
public bool Render(List<Image> imageStream, string outputPath, CancellationToken cancellationToken, Action<double>? progressCallback);
}
public enum QualityPreset
{
VeryLow = 0,
Low = 1,
Medium = 2,
High = 3,
VeryHigh = 4,
}

View File

@ -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<bool> RenderAsync(List<Image> rawFrames, string outputPath, CancellationToken cancellationToken,
Action<double>? 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");
});

View File

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

View File

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

View File

@ -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));

View File

@ -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;

View File

@ -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 @@
</TabControl.Styles>
<TabControl.Items>
<TabItem IsSelected="True" ui1:Translator.Key="EXPORT_IMAGE_HEADER" />
<TabItem ui1:Translator.Key="EXPORT_ANIMATION_HEADER" />
<TabItem ui1:Translator.Key="EXPORT_ANIMATION_HEADER">
<StackPanel Orientation="Vertical" Spacing="5">
<StackPanel Spacing="5" Orientation="Horizontal">
<TextBlock ui1:Translator.Key="ANIMATION_QUALITY_PRESET" />
<ComboBox
SelectedIndex="{Binding ElementName=saveFilePopup, Path=AnimationPresetIndex}"
ItemsSource="{Binding ElementName=saveFilePopup, Path=QualityPresetValues}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock
ui1:Translator.Key="{Binding Converter={converters:EnumToLocalizedStringConverter}}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<CheckBox IsChecked="{Binding ElementName=saveFilePopup, Path=FolderExport, Mode=TwoWay}"
ui1:Translator.Key="EXPORT_FRAMES" />
</StackPanel>
</StackPanel>
</TabItem>
<TabItem ui1:Translator.Key="EXPORT_SPRITESHEET_HEADER">
<Grid>
<Grid.ColumnDefinitions>

View File

@ -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<string> SizeHintProperty = AvaloniaProperty.Register<ExportFilePopup, string>(
nameof(SizeHint));
public static readonly StyledProperty<bool> FolderExportProperty = AvaloniaProperty.Register<ExportFilePopup, bool>(
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<int> AnimationPresetIndexProperty
= AvaloniaProperty.Register<ExportFilePopup, int>("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
/// </summary>
private async Task<string?> 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;
}
}
}