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 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 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 int FrameRate { get; set; } = 60;
public string OutputFormat { get; set; } = "mp4"; public string OutputFormat { get; set; } = "mp4";
public VecI Size { get; set; } public VecI Size { get; set; }
public QualityPreset QualityPreset { get; set; } = QualityPreset.VeryHigh;
public async Task<bool> RenderAsync(List<Image> rawFrames, string outputPath, CancellationToken cancellationToken, public async Task<bool> RenderAsync(List<Image> rawFrames, string outputPath, CancellationToken cancellationToken,
Action<double>? progressCallback = null) Action<double>? progressCallback = null)
@ -215,11 +216,20 @@ public class FFMpegRenderer : IAnimationRenderer
private FFMpegArgumentProcessor GetMp4Arguments(FFMpegArguments args, string outputPath) 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 return args
.OutputToFile(outputPath, true, options => .OutputToFile(outputPath, true, options =>
{ {
options.WithFramerate(FrameRate) options.WithFramerate(FrameRate)
.WithVideoBitrate(1800) .WithCustomArgument($"-qscale:v {qscale}")
.WithVideoCodec("mpeg4") .WithVideoCodec("mpeg4")
.ForcePixelFormat("yuv420p"); .ForcePixelFormat("yuv420p");
}); });

View File

@ -1059,5 +1059,12 @@
"STEP_START": "Step back to closest cel", "STEP_START": "Step back to closest cel",
"STEP_END": "Step forward to closest cel", "STEP_END": "Step forward to closest cel",
"STEP_FORWARD": "Step forward one frame", "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 VectorExportConfig? VectorExportConfig { get; set; }
public string ExportOutput { get; set; } public string ExportOutput { get; set; }
public bool ExportFramesToFolder { get; set; }
public ExportConfig(VecI exportSize) public ExportConfig(VecI exportSize)
{ {

View File

@ -5,10 +5,12 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using ChunkyImageLib; using ChunkyImageLib;
using Drawie.Backend.Core; using Drawie.Backend.Core;
using Drawie.Backend.Core.Surfaces;
using Drawie.Backend.Core.Surfaces.ImageData; using Drawie.Backend.Core.Surfaces.ImageData;
using PixiEditor.Helpers; using PixiEditor.Helpers;
using PixiEditor.Models.Files; using PixiEditor.Models.Files;
using Drawie.Numerics; using Drawie.Numerics;
using PixiEditor.UI.Common.Localization;
using PixiEditor.ViewModels.Document; using PixiEditor.ViewModels.Document;
namespace PixiEditor.Models.IO; namespace PixiEditor.Models.IO;
@ -114,6 +116,23 @@ internal class Exporter
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
return new SaveResult(SaveResultType.InvalidPath); 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)); var typeFromPath = SupportedFilesHelper.ParseImageFormat(Path.GetExtension(pathWithExtension));
if (typeFromPath is null) 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) public static void SaveAsGZippedBytes(string path, Surface surface)
{ {
SaveAsGZippedBytes(path, surface, new RectI(VecI.Zero, surface.Size)); 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.Files;
using PixiEditor.Models.IO; using PixiEditor.Models.IO;
using Drawie.Numerics; using Drawie.Numerics;
using PixiEditor.AnimationRenderer.Core;
using PixiEditor.ViewModels.Document; using PixiEditor.ViewModels.Document;
namespace PixiEditor.Views.Dialogs; namespace PixiEditor.Views.Dialogs;
@ -143,14 +144,16 @@ internal class ExportFileDialog : CustomDialog
FilePath = popup.SavePath; FilePath = popup.SavePath;
ChosenFormat = popup.SaveFormat; ChosenFormat = popup.SaveFormat;
ExportOutput = popup.ExportOutput; ExportOutput = popup.ExportOutput;
ExportConfig.ExportSize = new VecI(FileWidth, FileHeight); ExportConfig.ExportSize = new VecI(FileWidth, FileHeight);
ExportConfig.ExportOutput = ExportOutput.Name; ExportConfig.ExportOutput = ExportOutput.Name;
ExportConfig.ExportFramesToFolder = popup.FolderExport;
ExportConfig.AnimationRenderer = ChosenFormat is VideoFileType ? new FFMpegRenderer() ExportConfig.AnimationRenderer = ChosenFormat is VideoFileType ? new FFMpegRenderer()
{ {
Size = new VecI(FileWidth, FileHeight), Size = new VecI(FileWidth, FileHeight),
OutputFormat = ChosenFormat.PrimaryExtension.Replace(".", ""), OutputFormat = ChosenFormat.PrimaryExtension.Replace(".", ""),
FrameRate = document.AnimationDataViewModel.FrameRateBindable FrameRate = document.AnimationDataViewModel.FrameRateBindable,
QualityPreset = (QualityPreset)popup.AnimationPresetIndex
} }
: null; : null;
ExportConfig.ExportAsSpriteSheet = popup.IsSpriteSheetExport; ExportConfig.ExportAsSpriteSheet = popup.IsSpriteSheetExport;

View File

@ -7,6 +7,7 @@
xmlns:indicators="clr-namespace:PixiEditor.Views.Indicators" xmlns:indicators="clr-namespace:PixiEditor.Views.Indicators"
xmlns:input1="clr-namespace:PixiEditor.Views.Input" xmlns:input1="clr-namespace:PixiEditor.Views.Input"
xmlns:controls="clr-namespace:PixiEditor.UI.Common.Controls;assembly=PixiEditor.UI.Common" xmlns:controls="clr-namespace:PixiEditor.UI.Common.Controls;assembly=PixiEditor.UI.Common"
xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
CanResize="False" CanResize="False"
CanMinimize="False" CanMinimize="False"
SizeToContent="WidthAndHeight" SizeToContent="WidthAndHeight"
@ -27,7 +28,26 @@
</TabControl.Styles> </TabControl.Styles>
<TabControl.Items> <TabControl.Items>
<TabItem IsSelected="True" ui1:Translator.Key="EXPORT_IMAGE_HEADER" /> <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"> <TabItem ui1:Translator.Key="EXPORT_SPRITESHEET_HEADER">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>

View File

@ -13,6 +13,7 @@ using PixiEditor.Helpers;
using PixiEditor.Models.Files; using PixiEditor.Models.Files;
using PixiEditor.Models.IO; using PixiEditor.Models.IO;
using Drawie.Numerics; using Drawie.Numerics;
using PixiEditor.AnimationRenderer.Core;
using PixiEditor.UI.Common.Localization; using PixiEditor.UI.Common.Localization;
using PixiEditor.ViewModels.Document; using PixiEditor.ViewModels.Document;
using Image = Drawie.Backend.Core.Surfaces.ImageData.Image; 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>( public static readonly StyledProperty<string> SizeHintProperty = AvaloniaProperty.Register<ExportFilePopup, string>(
nameof(SizeHint)); 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 public string SizeHint
{ {
get => GetValue(SizeHintProperty); get => GetValue(SizeHintProperty);
@ -171,6 +180,14 @@ internal partial class ExportFilePopup : PixiEditorPopup
public bool IsSpriteSheetExport => SelectedExportIndex == 2; 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 DocumentViewModel document;
private Image[]? videoPreviewFrames = []; private Image[]? videoPreviewFrames = [];
private DispatcherTimer videoPreviewTimer = new DispatcherTimer(); private DispatcherTimer videoPreviewTimer = new DispatcherTimer();
@ -179,6 +196,9 @@ internal partial class ExportFilePopup : PixiEditorPopup
private Task? generateSpriteSheetTask; private Task? generateSpriteSheetTask;
public static readonly StyledProperty<int> AnimationPresetIndexProperty
= AvaloniaProperty.Register<ExportFilePopup, int>("AnimationPresetIndex", 4);
static ExportFilePopup() static ExportFilePopup()
{ {
SaveWidthProperty.Changed.Subscribe(RerenderPreview); SaveWidthProperty.Changed.Subscribe(RerenderPreview);
@ -193,8 +213,10 @@ internal partial class ExportFilePopup : PixiEditorPopup
{ {
SaveWidth = imageWidth; SaveWidth = imageWidth;
SaveHeight = imageHeight; SaveHeight = imageHeight;
QualityPresetValues = Enum.GetValues(typeof(QualityPreset));
InitializeComponent(); InitializeComponent();
DataContext = this; DataContext = this;
Loaded += (_, _) => sizePicker.FocusWidthPicker(); Loaded += (_, _) => sizePicker.FocusWidthPicker();
@ -467,37 +489,64 @@ internal partial class ExportFilePopup : PixiEditorPopup
/// </summary> /// </summary>
private async Task<string?> ChoosePath() private async Task<string?> ChoosePath()
{ {
FilePickerSaveOptions options = new FilePickerSaveOptions bool folderExport = FolderExport && SelectedExportIndex == 1;
{
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
};
IStorageFile file = await GetTopLevel(this).StorageProvider.SaveFilePickerAsync(options); if (folderExport)
if (file != null)
{ {
if (string.IsNullOrEmpty(file.Name) == false) FolderPickerOpenOptions options = new FolderPickerOpenOptions()
{ {
SaveFormat = SupportedFilesHelper.GetSaveFileType( Title = new LocalizedString("EXPORT_SAVE_TITLE"),
SelectedExportIndex == 1 SuggestedStartLocation = string.IsNullOrEmpty(document.FullFilePath)
? FileTypeDialogDataSet.SetKind.Video ? await GetTopLevel(this).StorageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents)
: FileTypeDialogDataSet.SetKind.Image | FileTypeDialogDataSet.SetKind.Vector, file); : await GetTopLevel(this).StorageProvider.TryGetFolderFromPathAsync(document.FullFilePath),
if (SaveFormat == null) 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;
}
} }
} }