Browse Source

Export as spritesheet is working

flabbet 1 year ago
parent
commit
f99285c55f

+ 31 - 0
src/PixiEditor.AvaloniaUI/Helpers/SpriteSheetUtility.cs

@@ -0,0 +1,31 @@
+namespace PixiEditor.AvaloniaUI.Helpers;
+
+public static class SpriteSheetUtility
+{
+    // calculate rows and columns so as little empty space is left
+    // For example 3 frames should be in 3x1 grid because 2x2 would leave 1 empty space, but 4 frames should be in 2x2 grid
+    public static (int rows, int columns) CalculateGridDimensionsAuto(int imagesLength)
+    {
+        int optimalRows = 1;
+        int optimalColumns = imagesLength;
+        int minDifference = Math.Abs(optimalRows - optimalColumns);
+
+        for (int rows = 1; rows <= Math.Sqrt(imagesLength); rows++)
+        {
+            int columns = (int)Math.Ceiling((double)imagesLength / rows);
+
+            if (rows * columns >= imagesLength)
+            {
+                int difference = Math.Abs(rows - columns);
+                if (difference < minDifference)
+                {
+                    minDifference = difference;
+                    optimalRows = rows;
+                    optimalColumns = columns;
+                }
+            }
+        }
+
+        return (optimalRows, optimalColumns);
+    }
+}

+ 51 - 9
src/PixiEditor.AvaloniaUI/Models/Files/ImageFileType.cs

@@ -1,10 +1,12 @@
 using System.Security;
 using ChunkyImageLib;
+using PixiEditor.AvaloniaUI.Helpers;
 using PixiEditor.AvaloniaUI.Models.IO;
 using PixiEditor.AvaloniaUI.Models.IO.FileEncoders;
 using PixiEditor.AvaloniaUI.ViewModels.Document;
 using PixiEditor.DrawingApi.Core.ColorsImpl;
 using PixiEditor.DrawingApi.Core.Surface;
+using PixiEditor.DrawingApi.Core.Surface.ImageData;
 using PixiEditor.Numerics;
 
 namespace PixiEditor.AvaloniaUI.Models.Files;
@@ -12,15 +14,26 @@ 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 async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig exportConfig)
+    public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document,
+        ExportConfig exportConfig)
     {
-        var maybeBitmap = document.TryRenderWholeImage();
-        if (maybeBitmap.IsT0)
-            return SaveResult.ConcurrencyError;
-        var bitmap = maybeBitmap.AsT1;
+        Surface finalSurface;
+        if (exportConfig.ExportAsSpriteSheet)
+        {
+            finalSurface = GenerateSpriteSheet(document, exportConfig);
+            if(finalSurface == null)
+                return SaveResult.UnknownError;
+        }
+        else
+        {
+            var maybeBitmap = document.TryRenderWholeImage();
+            if (maybeBitmap.IsT0)
+                return SaveResult.ConcurrencyError;
+            finalSurface = maybeBitmap.AsT1;
+        }
 
         EncodedImageFormat mappedFormat = EncodedImageFormat;
 
@@ -30,13 +43,42 @@ internal abstract class ImageFileType : IoFileType
         }
 
         UniversalFileEncoder encoder = new(mappedFormat);
-        return await TrySaveAs(encoder, pathWithExtension, bitmap, exportConfig);
+        var result = await TrySaveAs(encoder, pathWithExtension, finalSurface, exportConfig);
+        finalSurface.Dispose();
+        
+        return result;
+    }
+
+    private Surface? GenerateSpriteSheet(DocumentViewModel document, ExportConfig config)
+    {
+        if (document is null)
+            return null;
+
+        int framesCount = document.AnimationDataViewModel.FramesCount;
+
+        int rows, columns;
+        if(config.SpriteSheetRows == 0 || config.SpriteSheetColumns == 0)
+            (rows, columns) = SpriteSheetUtility.CalculateGridDimensionsAuto(framesCount);
+        else
+            (rows, columns) = (config.SpriteSheetRows, config.SpriteSheetColumns);
+
+        Surface surface = new Surface(new VecI(document.Width * columns, document.Height * rows));
+
+        document.RenderFramesProgressive((frame, index) =>
+        {
+            int x = index % columns;
+            int y = index / columns;
+            surface!.DrawingSurface.Canvas.DrawSurface(frame.DrawingSurface, x * document.Width, y * document.Height);
+        });
+        
+        return surface;
     }
-    
+
     /// <summary>
     /// Saves image to PNG file. Messes with the passed bitmap.
     /// </summary>
-    private static async Task<SaveResult> TrySaveAs(IFileEncoder encoder, string savePath, Surface bitmap, ExportConfig config)
+    private static async Task<SaveResult> TrySaveAs(IFileEncoder encoder, string savePath, Surface bitmap,
+        ExportConfig config)
     {
         try
         {

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

@@ -8,5 +8,8 @@ public class ExportConfig
 {
    public static ExportConfig Empty { get; } = new ExportConfig();
    public VecI? ExportSize { get; set; }
+   public bool ExportAsSpriteSheet { get; set; } = false;
+   public int SpriteSheetColumns { get; set; }
+   public int SpriteSheetRows { get; set; }
    public IAnimationRenderer? AnimationRenderer { get; set; }
 }

+ 5 - 1
src/PixiEditor.AvaloniaUI/ViewModels/Document/AnimationDataViewModel.cs

@@ -43,7 +43,11 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
             _frameRate = value;
             OnPropertyChanged(nameof(FrameRate));
         }
-    } 
+    }
+
+    public int FirstFrame => keyFrames.Min(x => x.StartFrameBindable);
+    public int LastFrame => keyFrames.Max(x => x.StartFrameBindable + x.DurationBindable);
+    public int FramesCount => LastFrame - FirstFrame; 
 
     public AnimationDataViewModel(DocumentViewModel document, DocumentInternalParts internals)
     {

+ 41 - 4
src/PixiEditor.AvaloniaUI/ViewModels/Document/DocumentViewModel.cs

@@ -694,13 +694,13 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         if (AnimationDataViewModel.KeyFrames.Count == 0)
             return [];
 
-        var keyFrames = AnimationDataViewModel.KeyFrames;
-        var firstFrame = keyFrames.Min(x => x.StartFrameBindable);
-        var lastFrame = keyFrames.Max(x => x.StartFrameBindable + x.DurationBindable);
+        int firstFrame = AnimationDataViewModel.FirstFrame;
+        int framesCount = AnimationDataViewModel.FramesCount;
+        int lastFrame = firstFrame + framesCount;
 
         int activeFrame = AnimationDataViewModel.ActiveFrameBindable;
 
-        Image[] images = new Image[lastFrame - firstFrame];
+        Image[] images = new Image[framesCount];
         for (int i = firstFrame; i < lastFrame; i++)
         {
             Internals.Tracker.ProcessActionsSync(new List<IAction>
@@ -729,6 +729,43 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         return images;
     }
 
+    /// <summary>
+    ///     Render frames progressively and disposes the surface after processing.
+    /// </summary>
+    /// <param name="processFrameAction">Action to perform on rendered frame</param>
+    public void RenderFramesProgressive(Action<Surface, int> processFrameAction)
+    {
+        if (AnimationDataViewModel.KeyFrames.Count == 0)
+            return;
+
+        int firstFrame = AnimationDataViewModel.FirstFrame;
+        int framesCount = AnimationDataViewModel.FramesCount;
+        int lastFrame = firstFrame + framesCount;
+
+        int activeFrame = AnimationDataViewModel.ActiveFrameBindable;
+
+        for (int i = firstFrame; i < lastFrame; i++)
+        {
+            Internals.Tracker.ProcessActionsSync(new List<IAction>
+            {
+                new ActiveFrame_Action(i), new EndActiveFrame_Action()
+            });
+            var surface = TryRenderWholeImage();
+            if (surface.IsT0)
+            {
+                continue;
+            }
+
+            processFrameAction(surface.AsT1, i - firstFrame);
+            surface.AsT1.Dispose();
+        }
+
+        Internals.Tracker.ProcessActionsSync(new List<IAction>
+        {
+            new ActiveFrame_Action(activeFrame), new EndActiveFrame_Action()
+        });
+    }
+
     public bool RenderFrames(string tempRenderingPath, Func<Surface, Surface> processFrameAction = null)
     {
         if (AnimationDataViewModel.KeyFrames.Count == 0)

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

@@ -76,54 +76,7 @@ internal class AnimationsViewModel : SubViewModel<ViewModelMain>
         }
     }
     
-    [Command.Basic("PixiEditor.Animation.ExportSpriteSheet", "Export Sprite Sheet", "Export the sprite sheet")]
-    public async Task ExportSpriteSheet()
-    {
-        var document = Owner.DocumentManagerSubViewModel.ActiveDocument;
-        
-        if (document is null)
-            return;
-        
-        Image[] images = document.RenderFrames();
-        // calculate rows and columns so as little empty space is left
-        // For example 3 frames should be in 3x1 grid because 2x2 would leave 1 empty space, but 4 frames should be in 2x2 grid
-        (int rows, int columns) grid = CalculateGridDimensions(images.Length);
-        
-        using Surface surface = new Surface(new VecI(document.Width * grid.columns, document.Height * grid.rows));
-        for (int i = 0; i < images.Length; i++)
-        {
-            int x = i % grid.columns;
-            int y = i / grid.columns;
-            surface.DrawingSurface.Canvas.DrawImage(images[i], x * document.Width, y * document.Height);
-        }
-        surface.SaveToDesktop();
-    }
-
-    private (int rows, int columns) CalculateGridDimensions(int imagesLength)
-    {
-        int optimalRows = 1;
-        int optimalColumns = imagesLength;
-        int minDifference = Math.Abs(optimalRows - optimalColumns);
-
-        for (int rows = 1; rows <= Math.Sqrt(imagesLength); rows++)
-        {
-            int columns = (int)Math.Ceiling((double)imagesLength / rows);
-
-            if (rows * columns >= imagesLength)
-            {
-                int difference = Math.Abs(rows - columns);
-                if (difference < minDifference)
-                {
-                    minDifference = difference;
-                    optimalRows = rows;
-                    optimalColumns = columns;
-                }
-            }
-        }
-
-        return (optimalRows, optimalColumns);
-    }
-
+    
     private static int GetActiveFrame(DocumentViewModel activeDocument, Guid targetLayer)
     {
         int active = activeDocument.AnimationDataViewModel.ActiveFrameBindable;

+ 3 - 1
src/PixiEditor.AvaloniaUI/Views/Dialogs/ExportFileDialog.cs

@@ -111,7 +111,9 @@ internal class ExportFileDialog : CustomDialog
                 Size = new VecI(FileWidth, FileHeight),
                 OutputFormat = ChosenFormat.PrimaryExtension.Replace(".", ""),
                 FrameRate = 60
-            } : null;
+            }
+            : null;
+            ExportConfig.ExportAsSpriteSheet = popup.IsSpriteSheetExport;
         }
 
         return result;

+ 29 - 21
src/PixiEditor.AvaloniaUI/Views/Dialogs/ExportFilePopup.axaml

@@ -15,17 +15,22 @@
     <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"
+        <StackPanel HorizontalAlignment="Center" VerticalAlignment="Stretch" Orientation="Vertical"
                     Margin="0,15,0,0">
             <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">
+                        <StackPanel>
+                            <input:NumberInput Min="0" />
+                            <input:NumberInput Min="0" />
+                        </StackPanel>
                     </TabItem>
-                    <TabItem ui1:Translator.Key="EXPORT_SPRITESHEET_HEADER"/>
                 </TabControl.Items>
             </TabControl>
             <Border Margin="15, 30" Padding="10"
@@ -34,19 +39,19 @@
                 <Grid MinHeight="205" MinWidth="400">
                     <Grid.ColumnDefinitions>
                         <ColumnDefinition Width="*" />
-                        <ColumnDefinition Width="160"/>
+                        <ColumnDefinition Width="160" />
                     </Grid.ColumnDefinitions>
                     <Grid>
                         <Grid.RowDefinitions>
-                            <RowDefinition Height="Auto"/>
+                            <RowDefinition Height="Auto" />
                             <RowDefinition Height="Auto" />
                         </Grid.RowDefinitions>
                         <input:SizePicker Grid.Row="0"
-                            x:Name="sizePicker"
-                            IsSizeUnitSelectionVisible="True"
-                            VerticalAlignment="Top"
-                            ChosenHeight="{Binding Path=SaveHeight, Mode=TwoWay, ElementName=saveFilePopup}"
-                            ChosenWidth="{Binding Path=SaveWidth, Mode=TwoWay, ElementName=saveFilePopup}" />
+                                          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"
                                    TextWrapping="Wrap"
                                    Width="220" TextAlignment="Center"
@@ -61,22 +66,25 @@
                     </Grid>
                     <Grid Grid.Column="1">
                         <Grid.RowDefinitions>
-                            <RowDefinition Height="30"/>
-                            <RowDefinition Height="Auto"/>
+                            <RowDefinition Height="30" />
+                            <RowDefinition Height="Auto" />
                         </Grid.RowDefinitions>
-                        
-                        <TextBlock Text="Export Preview"/>
-                        <indicators:LoadingIndicator Grid.Row="1" IsVisible="{Binding IsGeneratingPreview, ElementName=saveFilePopup}"
-                                          Margin="0, 10, 0, 0"/>
-                        <Border Grid.Row="1" BorderThickness="1" Height="200" Width="150" IsVisible="{Binding !IsGeneratingPreview, ElementName=saveFilePopup}">
+
+                        <TextBlock Text="Export Preview" />
+                        <indicators:LoadingIndicator Grid.Row="1"
+                                                     IsVisible="{Binding IsGeneratingPreview, ElementName=saveFilePopup}"
+                                                     Margin="0, 10, 0, 0" />
+                        <Border Grid.Row="1" BorderThickness="1" Height="200" Width="150"
+                                IsVisible="{Binding !IsGeneratingPreview, ElementName=saveFilePopup}">
                             <Border RenderOptions.BitmapInterpolationMode="None">
                                 <visuals:SurfaceControl x:Name="surfaceControl"
                                                         Surface="{Binding ExportPreview, ElementName=saveFilePopup}"
-                                                        Stretch="Uniform" HorizontalAlignment="Center" VerticalAlignment="Center"
+                                                        Stretch="Uniform" HorizontalAlignment="Center"
+                                                        VerticalAlignment="Center"
                                                         RenderOptions.BitmapInterpolationMode="None">
                                     <visuals:SurfaceControl.Background>
-                                        <ImageBrush Source="/Images/CheckerTile.png" 
-                                                    TileMode="Tile" DestinationRect="0, 0, 25, 25"/>
+                                        <ImageBrush Source="/Images/CheckerTile.png"
+                                                    TileMode="Tile" DestinationRect="0, 0, 25, 25" />
                                     </visuals:SurfaceControl.Background>
                                 </visuals:SurfaceControl>
                             </Border>

+ 46 - 31
src/PixiEditor.AvaloniaUI/Views/Dialogs/ExportFilePopup.axaml.cs

@@ -115,6 +115,9 @@ internal partial class ExportFilePopup : PixiEditorPopup
     }
 
     public bool IsVideoExport => SelectedExportIndex == 1;
+
+    public bool IsSpriteSheetExport => SelectedExportIndex == 2;
+
     public string SizeHint => new LocalizedString("EXPORT_SIZE_HINT", GetBestPercentage());
 
     private DocumentViewModel document;
@@ -207,20 +210,30 @@ internal partial class ExportFilePopup : PixiEditorPopup
         }
         else
         {
-            var rendered = document.TryRenderWholeImage();
-            if (rendered.IsT1)
-            {
-                VecI previewSize = CalculatePreviewSize(rendered.AsT1.Size);
-                ExportPreview = rendered.AsT1.ResizeNearestNeighbor(previewSize);
-                rendered.AsT1.Dispose();
-                IsGeneratingPreview = false;
-            }
+            RenderImagePreview();
+        }
+    }
+
+    private void RenderImagePreview()
+    {
+        if (IsSpriteSheetExport)
+        {
+            //GenerateSpriteSheetPreview();
+        }
+
+        var rendered = document.TryRenderWholeImage();
+        if (rendered.IsT1)
+        {
+            VecI previewSize = CalculatePreviewSize(rendered.AsT1.Size);
+            ExportPreview = rendered.AsT1.ResizeNearestNeighbor(previewSize);
+            rendered.AsT1.Dispose();
+            IsGeneratingPreview = false;
         }
     }
 
     private void StartRenderAnimationJob()
     {
-        if (cancellationTokenSource.Token != null && cancellationTokenSource.Token.CanBeCanceled)
+        if (cancellationTokenSource.Token is { CanBeCanceled: true })
         {
             cancellationTokenSource.Cancel();
         }
@@ -230,28 +243,7 @@ internal partial class ExportFilePopup : PixiEditorPopup
         Task.Run(
             () =>
             {
-                videoPreviewFrames = document.RenderFrames(surface =>
-                {
-                    return Dispatcher.UIThread.Invoke(() =>
-                    {
-                        Surface original = surface;
-                        if (SaveWidth != surface.Size.X || SaveHeight != surface.Size.Y)
-                        {
-                            original = surface.ResizeNearestNeighbor(new VecI(SaveWidth, SaveHeight));
-                            surface.Dispose();
-                        }
-
-                        VecI previewSize = CalculatePreviewSize(original.Size);
-                        if (previewSize != original.Size)
-                        {
-                            var resized = original.ResizeNearestNeighbor(previewSize);
-                            original.Dispose();
-                            return resized;
-                        }
-
-                        return original;
-                    });
-                });
+                videoPreviewFrames = document.RenderFrames(ProcessFrame);
             }, cancellationTokenSource.Token).ContinueWith(_ =>
         {
             Dispatcher.UIThread.Invoke(() =>
@@ -270,6 +262,29 @@ internal partial class ExportFilePopup : PixiEditorPopup
         });
     }
 
+    private Surface ProcessFrame(Surface surface)
+    {
+        return Dispatcher.UIThread.Invoke(() =>
+        {
+            Surface original = surface;
+            if (SaveWidth != surface.Size.X || SaveHeight != surface.Size.Y)
+            {
+                original = surface.ResizeNearestNeighbor(new VecI(SaveWidth, SaveHeight));
+                surface.Dispose();
+            }
+
+            VecI previewSize = CalculatePreviewSize(original.Size);
+            if (previewSize != original.Size)
+            {
+                var resized = original.ResizeNearestNeighbor(previewSize);
+                original.Dispose();
+                return resized;
+            }
+
+            return original;
+        });
+    }
+
     private VecI CalculatePreviewSize(VecI imageSize)
     {
         VecI maxPreviewSize = new VecI(150, 200);

+ 1 - 1
src/PixiEditor.AvaloniaUI/Views/Visuals/SurfaceControl.cs

@@ -170,7 +170,7 @@ internal class DrawSurfaceOperation : SkiaDrawOperation
         // preview updater is disposing the surface and creating a new one, but it should also
         // update the control to use the new surface, debugging at SurfaceControl Render() never returns disposed surface.
         // Probably some kind of race condition between dispose and render queue.
-        if (Surface == null || Surface.IsDisposed) 
+        if (Surface == null || Surface.IsDisposed)
         {
             return;
         }