Browse Source

Some progress

Krzysztof Krysiński 6 months ago
parent
commit
ee4c8300dc
42 changed files with 1227 additions and 71 deletions
  1. 1 0
      src/PixiEditor.AnimationRenderer.Core/IAnimationRenderer.cs
  2. 65 0
      src/PixiEditor.AnimationRenderer.FFmpeg/FFMpegRenderer.cs
  3. 14 0
      src/PixiEditor.Extensions.CommonApi/UserPreferences/PreferencesConstants.cs
  4. 20 0
      src/PixiEditor/Helpers/AutosaveUtility.cs
  5. 3 0
      src/PixiEditor/Models/Constants.cs
  6. 9 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaveHistoryEntry.cs
  7. 8 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaveHistoryResult.cs
  8. 8 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaveHistorySession.cs
  9. 7 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaveHistoryType.cs
  10. 16 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaveSaveData.cs
  11. 9 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaveState.cs
  12. 10 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaveStateData.cs
  13. 27 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaverAwaitUpdateableChangeEndJob.cs
  14. 76 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaverSaveBackupJob.cs
  15. 70 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaverSaveUserFileJob.cs
  16. 31 0
      src/PixiEditor/Models/DocumentModels/Autosave/AutosaverWaitJob.cs
  17. 9 0
      src/PixiEditor/Models/DocumentModels/Autosave/BackupAutosaveResult.cs
  18. 207 0
      src/PixiEditor/Models/DocumentModels/Autosave/DocumentAutosaver.cs
  19. 10 0
      src/PixiEditor/Models/DocumentModels/Autosave/IAutosaverJob.cs
  20. 7 0
      src/PixiEditor/Models/DocumentModels/Autosave/LastUserFileAutosaveData.cs
  21. 8 0
      src/PixiEditor/Models/DocumentModels/Autosave/UserFileAutosaveResult.cs
  22. 6 6
      src/PixiEditor/Models/DocumentModels/ChangeExecutionController.cs
  23. 14 0
      src/PixiEditor/Models/DocumentPassthroughActions/MarkAsAutosaved_PassthroughAction.cs
  24. 52 8
      src/PixiEditor/Models/ExceptionHandling/CrashReport.cs
  25. 3 1
      src/PixiEditor/Models/ExceptionHandling/CrashedFileInfo.cs
  26. 102 23
      src/PixiEditor/Models/Files/ImageFileType.cs
  27. 2 1
      src/PixiEditor/Models/Files/IoFileType.cs
  28. 8 1
      src/PixiEditor/Models/Files/OtfFileType.cs
  29. 29 2
      src/PixiEditor/Models/Files/PixiFileType.cs
  30. 24 3
      src/PixiEditor/Models/Files/SvgFileType.cs
  31. 8 1
      src/PixiEditor/Models/Files/TtfFileType.cs
  32. 54 9
      src/PixiEditor/Models/Files/VideoFileType.cs
  33. 5 0
      src/PixiEditor/Models/IO/ExportConfig.cs
  34. 30 2
      src/PixiEditor/Models/IO/Exporter.cs
  35. 23 8
      src/PixiEditor/Models/IO/Paths.cs
  36. 136 0
      src/PixiEditor/ViewModels/Document/AutosaveDocumentViewModel.cs
  37. 6 0
      src/PixiEditor/ViewModels/Document/DocumentViewModel.cs
  38. 59 0
      src/PixiEditor/ViewModels/SubViewModels/AutosaveViewModel.cs
  39. 47 4
      src/PixiEditor/ViewModels/SubViewModels/FileViewModel.cs
  40. 2 0
      src/PixiEditor/ViewModels/ViewModelMain.cs
  41. 1 1
      src/PixiEditor/Views/Dialogs/ExportFileDialog.cs
  42. 1 1
      src/PixiEditor/Views/MainWindow.axaml.cs

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

@@ -5,4 +5,5 @@ namespace PixiEditor.AnimationRenderer.Core;
 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);
 }

+ 65 - 0
src/PixiEditor.AnimationRenderer.FFmpeg/FFMpegRenderer.cs

@@ -83,6 +83,71 @@ public class FFMpegRenderer : IAnimationRenderer
         }
     }
 
+    public bool Render(List<Image> rawFrames, string outputPath, CancellationToken cancellationToken,
+        Action<double>? progressCallback)
+    {
+        string path = $"ThirdParty/{IOperatingSystem.Current.Name}/ffmpeg";
+
+        string binaryPath = Path.Combine(Path.GetDirectoryName(AppDomain.CurrentDomain.BaseDirectory), path);
+
+        GlobalFFOptions.Configure(new FFOptions() { BinaryFolder = binaryPath });
+
+        if (IOperatingSystem.Current.IsUnix)
+        {
+            MakeExecutableIfNeeded(binaryPath);
+        }
+
+        string paletteTempPath = Path.Combine(Path.GetDirectoryName(outputPath), "RenderTemp", "palette.png");
+
+        try
+        {
+            List<ImgFrame> frames = new();
+
+            foreach (var frame in rawFrames)
+            {
+                frames.Add(new ImgFrame(frame));
+            }
+
+            RawVideoPipeSource streamPipeSource = new(frames) { FrameRate = FrameRate, };
+
+
+            if (!Directory.Exists(Path.GetDirectoryName(paletteTempPath)))
+            {
+                Directory.CreateDirectory(Path.GetDirectoryName(paletteTempPath));
+            }
+
+            if (RequiresPaletteGeneration())
+            {
+                GeneratePalette(streamPipeSource, paletteTempPath);
+            }
+
+            streamPipeSource = new(frames) { FrameRate = FrameRate, };
+
+            var args = FFMpegArguments
+                .FromPipeInput(streamPipeSource, options =>
+                {
+                    options.WithFramerate(FrameRate);
+                });
+
+            var outputArgs = GetProcessorForFormat(args, outputPath, paletteTempPath);
+            TimeSpan totalTimeSpan = TimeSpan.FromSeconds(frames.Count / (float)FrameRate);
+            var result = outputArgs.CancellableThrough(cancellationToken)
+                .NotifyOnProgress(progressCallback, totalTimeSpan).ProcessSynchronously();
+
+            DisposeStream(frames);
+
+            return result;
+        }
+        finally
+        {
+            if (RequiresPaletteGeneration() && File.Exists(paletteTempPath))
+            {
+                File.Delete(paletteTempPath);
+                Directory.Delete(Path.GetDirectoryName(paletteTempPath));
+            }
+        }
+    }
+
     private static void MakeExecutableIfNeeded(string binaryPath)
     {
         string filePath = Path.Combine(binaryPath, "ffmpeg");

+ 14 - 0
src/PixiEditor.Extensions.CommonApi/UserPreferences/PreferencesConstants.cs

@@ -10,4 +10,18 @@ public static class PreferencesConstants
     public const string DisableNewsPanel = "DisableNewsPanel";
     public const string LastCheckedNewsIds = "LastCheckedNewsIds";
     public const string NewsPanelCollapsed = "NewsPanelCollapsed";
+
+    public const string SaveSessionStateEnabled = "SaveSessionStateEnabled";
+    public const bool SaveSessionStateDefault = true;
+
+    public const string AutosaveEnabled = "AutosaveEnabled";
+    public const bool AutosaveEnabledDefault = true;
+
+    public const string AutosaveHistory = "AutosaveHistory";
+
+    public const string AutosavePeriodMinutes = "AutosavePeriodMinutes";
+    public const double AutosavePeriodDefault = 3;
+
+    public const string AutosaveToDocumentPath = "AutosaveToDocumentPath";
+    public const bool AutosaveToDocumentPathDefault = false;
 }

+ 20 - 0
src/PixiEditor/Helpers/AutosaveUtility.cs

@@ -0,0 +1,20 @@
+using PixiEditor.Models.IO;
+
+namespace PixiEditor.Helpers;
+
+internal class AutosaveHelper
+{
+    public static Guid? GetAutosaveGuid(string? path)
+    {
+        if (path is null)
+            return null;
+
+        string guidString = Path.GetFileNameWithoutExtension(path)["autosave-".Length..];
+        return Guid.Parse(guidString);
+    }
+
+    public static string GetAutosavePath(Guid guid)
+    {
+        return Path.Join(Paths.PathToUnsavedFilesFolder, $"autosave-{guid}.pixi");
+    }
+}

+ 3 - 0
src/PixiEditor/Models/Constants.cs

@@ -10,4 +10,7 @@ internal class Constants
 
     public const string NativeExtensionNoDot = "pixi";
     public const string NativeExtension = "." + NativeExtensionNoDot;
+
+    public const double MaxAutosaveFilesLifetimeDays = 10;
+
 }

+ 9 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaveHistoryEntry.cs

@@ -0,0 +1,9 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal class AutosaveHistoryEntry(DateTime dateTime, AutosaveHistoryType type, AutosaveHistoryResult result, Guid tempFileGuid)
+{
+    public DateTime DateTime { get; set; } = dateTime;
+    public AutosaveHistoryType Type { get; set; } = type;
+    public AutosaveHistoryResult Result { get; set; } = result;
+    public Guid TempFileGuid { get; set; } = tempFileGuid;
+}

+ 8 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaveHistoryResult.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal enum AutosaveHistoryResult
+{
+    SavedUserFile,
+    SavedBackup,
+    NothingToSave
+}

+ 8 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaveHistorySession.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal class AutosaveHistorySession(Guid sessionGuid, DateTime launchDateTime)
+{
+    public List<AutosaveHistoryEntry> AutosaveEntries { get; set; } = new();
+    public Guid SessionGuid { get; set; } = sessionGuid;
+    public DateTime LaunchDateTime { get; set; } = launchDateTime;
+}

+ 7 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaveHistoryType.cs

@@ -0,0 +1,7 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal enum AutosaveHistoryType
+{
+    Periodic,
+    OnClose
+}

+ 16 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaveSaveData.cs

@@ -0,0 +1,16 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+public struct AutosaveSaveData
+{
+    public LastBackupAutosaveData? LastBackupAutosaveData { get; set; }
+    public LastUserFileAutosaveData? LastUserFileAutosaveData { get; set; }
+    public AutosaveState AutosaveState { get; set; }
+    public DateTime AutosaveLaunchDateTime { get; set; }
+    public TimeSpan AutosaveInterval { get; set; }
+}
+
+public struct LastBackupAutosaveData
+{
+    public DateTime Time { get; set; }
+    public BackupAutosaveResult SaveResult { get; set; }
+}

+ 9 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaveState.cs

@@ -0,0 +1,9 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+public enum AutosaveState
+{
+    Paused,
+    Idle,
+    AwaitingUpdateableChangeEnd,
+    InProgress
+}

+ 10 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaveStateData.cs

@@ -0,0 +1,10 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+public struct AutosaveStateData
+{
+    public LastBackupAutosaveData? LastBackupAutosaveData { get; set; }
+    public LastUserFileAutosaveData? LastUserFileAutosaveData { get; set; }
+    public AutosaveState AutosaveState { get; set; }
+    public DateTime AutosaveLaunchDateTime { get; set; }
+    public TimeSpan AutosaveInterval { get; set; }
+}

+ 27 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaverAwaitUpdateableChangeEndJob.cs

@@ -0,0 +1,27 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal class AutosaverAwaitUpdateableChangeEndJob : IAutosaverJob
+{
+    public event Action? OnCompleted;
+    private bool isStopped = true;
+
+    public AutosaveState CorrespondingState => AutosaveState.AwaitingUpdateableChangeEnd;
+
+    public void OnUpdateableChangeEnded()
+    {
+        if (isStopped)
+            return;
+        OnCompleted?.Invoke();
+        isStopped = true;
+    }
+
+    public void Start()
+    {
+        isStopped = false;
+    }
+
+    public void ForceStop()
+    {
+        isStopped = true;
+    }
+}

+ 76 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaverSaveBackupJob.cs

@@ -0,0 +1,76 @@
+using Avalonia.Threading;
+using PixiEditor.Models.IO;
+using PixiEditor.ViewModels.Document;
+
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal class AutosaverSaveBackupJob(DocumentViewModel documentToSave, int backupAttempt = 1) : IAutosaverJob
+{
+    public event Action? OnCompleted;
+    public event Action<AutosaverSaveBackupJob, BackupAutosaveResult>? OnNonCompleted;
+    public Exception? Exception { get; private set; }
+
+    public AutosaveState CorrespondingState => AutosaveState.InProgress;
+
+    private DispatcherTimer? waitingTimer;
+
+    public void Start()
+    {
+        string filePath = documentToSave.AutosaveViewModel.AutosavePath;
+        BackupAutosaveResult result = Autosave(filePath);
+        if (result == BackupAutosaveResult.Success)
+        {
+            documentToSave.AutosaveViewModel.LastAutosavedPath = filePath;
+            documentToSave.MarkAsAutosaved();
+        }
+
+        if (result == BackupAutosaveResult.Success)
+            OnCompleted?.Invoke();
+        else
+            OnNonCompleted?.Invoke(this, result);
+    }
+
+    public void OnUpdateableChangeEnded() { }
+
+    public void ForceStop()
+    {
+        waitingTimer!.Stop();
+    }
+
+    private BackupAutosaveResult Autosave(string filePath)
+    {
+        if (documentToSave.AllChangesSaved)
+        {
+            documentToSave.AutosaveViewModel.AddAutosaveHistoryEntry(AutosaveHistoryType.Periodic,
+                AutosaveHistoryResult.NothingToSave);
+            return BackupAutosaveResult.NothingToSave;
+        }
+
+        if (documentToSave.BlockingUpdateableChangeActive)
+            return BackupAutosaveResult.BlockedByUpdateableChange;
+
+        try
+        {
+            Directory.CreateDirectory(Directory.GetParent(filePath)!.FullName);
+
+            ExportConfig config = new ExportConfig(documentToSave.SizeBindable);
+            var result = Exporter.TrySave(documentToSave, filePath, config, null);
+
+            if (result == SaveResult.Success)
+            {
+                documentToSave.MarkAsAutosaved();
+                documentToSave.AutosaveViewModel.AddAutosaveHistoryEntry(AutosaveHistoryType.Periodic,
+                    AutosaveHistoryResult.SavedBackup);
+                return BackupAutosaveResult.Success;
+            }
+
+            Exception = new Exception($"Failed to autosave for the {backupAttempt}. time due to {result}");
+            return BackupAutosaveResult.Error;
+        }
+        catch (Exception e)
+        {
+            Exception = e;
+            return BackupAutosaveResult.Error;
+        }
+    }
+}

+ 70 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaverSaveUserFileJob.cs

@@ -0,0 +1,70 @@
+using Avalonia.Threading;
+using PixiEditor.ViewModels.Document;
+
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal class AutosaverSaveUserFileJob(DocumentViewModel document) : IAutosaverJob
+{
+    public event Action OnCompleted;
+    public event Action<AutosaverSaveUserFileJob, UserFileAutosaveResult>? OnNonCompleted;
+    public Exception? Exception { get; private set; }
+
+    public AutosaveState CorrespondingState => AutosaveState.InProgress;
+
+    private DispatcherTimer? waitingTimer;
+
+    public async void Start()
+    {
+        UserFileAutosaveResult result;
+        try
+        {
+            result = await Task.Run(Copy);
+        }
+        catch (Exception e)
+        {
+            result = UserFileAutosaveResult.ExceptionWhileSaving;
+            Exception = e;
+        }
+
+        if (result == UserFileAutosaveResult.Success)
+        {
+            document.MarkAsSaved();
+        }
+
+        if (result == UserFileAutosaveResult.Success)
+            OnCompleted?.Invoke();
+        else
+            OnNonCompleted?.Invoke(this, result);
+
+        UserFileAutosaveResult Copy()
+        {
+            try
+            {
+                string path = document.FullFilePath;
+                if (!File.Exists(path))
+                    return UserFileAutosaveResult.NoUserFile;
+
+                File.Copy(document.AutosaveViewModel.LastAutosavedPath, path, true);
+
+                document.MarkAsSaved();
+                document.AutosaveViewModel.AddAutosaveHistoryEntry(AutosaveHistoryType.Periodic, AutosaveHistoryResult.SavedUserFile);
+                return UserFileAutosaveResult.Success;
+            }
+            catch (Exception e) when (e is UnauthorizedAccessException or DirectoryNotFoundException)
+            {
+                return UserFileAutosaveResult.NoUserFile;
+            }
+            catch (Exception e)
+            {
+                Exception = e;
+                return UserFileAutosaveResult.ExceptionWhileSaving;
+            }
+        }
+    }
+
+    public void OnUpdateableChangeEnded() { }
+    public void ForceStop()
+    {
+        waitingTimer!.Stop();
+    }
+}

+ 31 - 0
src/PixiEditor/Models/DocumentModels/Autosave/AutosaverWaitJob.cs

@@ -0,0 +1,31 @@
+using Avalonia.Threading;
+
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal class AutosaverWaitJob(TimeSpan duration)
+    : IAutosaverJob
+{
+    public event Action? OnCompleted;
+    private DispatcherTimer? waitingTimer;
+
+    public AutosaveState CorrespondingState => AutosaveState.Idle;
+
+    public void Start()
+    {
+        waitingTimer = new(duration, DispatcherPriority.Normal, WaitEndCallback);
+        waitingTimer.Start();
+    }
+
+    private void WaitEndCallback(object sender, EventArgs e)
+    {
+        waitingTimer!.Stop();
+        OnCompleted?.Invoke();
+    }
+
+    public void OnUpdateableChangeEnded() { }
+
+    public void ForceStop()
+    {
+        waitingTimer!.Stop();
+    }
+}

+ 9 - 0
src/PixiEditor/Models/DocumentModels/Autosave/BackupAutosaveResult.cs

@@ -0,0 +1,9 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+public enum BackupAutosaveResult
+{
+    Success,
+    Error,
+    NothingToSave,
+    BlockedByUpdateableChange
+}

+ 207 - 0
src/PixiEditor/Models/DocumentModels/Autosave/DocumentAutosaver.cs

@@ -0,0 +1,207 @@
+using PixiEditor.Helpers;
+using PixiEditor.ViewModels.Document;
+
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal class DocumentAutosaver : IDisposable
+{
+    public event EventHandler? JobChanged;
+
+    public AutosaveStateData State
+    {
+        get
+        {
+            return new AutosaveStateData
+            {
+                AutosaveInterval = autosavePeriod,
+                AutosaveLaunchDateTime = autosaveStartedAt,
+                AutosaveState = currentJob?.CorrespondingState ?? AutosaveState.Idle,
+                LastBackupAutosaveData =
+                    lastBackupAutosaveResult is null
+                        ? null
+                        : new LastBackupAutosaveData
+                        {
+                            Time = lastBackupAutosaveDateTime.Value, SaveResult = lastBackupAutosaveResult.Value
+                        },
+                LastUserFileAutosaveData = lastUserFileAutosaveResult is null
+                    ? null
+                    : new LastUserFileAutosaveData
+                    {
+                        Time = lastUserFileAutosaveDateTime.Value, SaveResult = lastUserFileAutosaveResult.Value
+                    }
+            };
+        }
+    }
+
+    private readonly DateTime autosaveStartedAt;
+
+    private int backupSaveFailureCount = 0;
+    private int userFileSaveFailureCount = 0;
+
+    private IAutosaverJob? currentJob;
+
+    private IAutosaverJob? CurrentJob
+    {
+        get => currentJob;
+        set
+        {
+            currentJob = value;
+            JobChanged?.Invoke(this, EventArgs.Empty);
+        }
+    }
+
+    private readonly bool saveUserFile;
+    private readonly DocumentViewModel document;
+    private readonly TimeSpan autosavePeriod;
+
+    private bool isDisposed = false;
+
+    private UserFileAutosaveResult? lastUserFileAutosaveResult;
+    private DateTime? lastUserFileAutosaveDateTime;
+    private BackupAutosaveResult? lastBackupAutosaveResult;
+    private DateTime? lastBackupAutosaveDateTime;
+
+    public DocumentAutosaver(DocumentViewModel document, TimeSpan autosavePeriod, bool saveUserFile)
+    {
+        this.document = document;
+        this.autosavePeriod = autosavePeriod;
+        this.saveUserFile = saveUserFile;
+        autosaveStartedAt = DateTime.Now;
+
+        AutosaverWaitJob initialWaitJob = new(autosavePeriod);
+        initialWaitJob.OnCompleted += OnWaitingCompleted;
+        StartJob(initialWaitJob);
+    }
+
+    public void OnUpdateableChangeEnded() => CurrentJob?.OnUpdateableChangeEnded();
+
+    private void OnWaitingCompleted()
+    {
+        if (isDisposed)
+            return;
+        InitiateSaving();
+    }
+
+    private void StartJob(IAutosaverJob job)
+    {
+        CurrentJob = job;
+        job.Start();
+    }
+
+    private void WaitForNextSave()
+    {
+        AutosaverWaitJob waitJob = new(autosavePeriod);
+        waitJob.OnCompleted += OnWaitingCompleted;
+        StartJob(waitJob);
+    }
+
+    private void InitiateSaving()
+    {
+        AutosaverSaveBackupJob saveBackupJob = new(document, backupSaveFailureCount + 1);
+        saveBackupJob.OnCompleted += OnBackupSavingCompleted;
+        saveBackupJob.OnNonCompleted += OnBackupSavingNonCompleted;
+        StartJob(saveBackupJob);
+    }
+
+    private void OnBackupSavingCompleted()
+    {
+        if (isDisposed)
+            return;
+
+        lastBackupAutosaveResult = BackupAutosaveResult.Success;
+        lastBackupAutosaveDateTime = DateTime.Now;
+
+        backupSaveFailureCount = 0;
+        if (saveUserFile)
+        {
+            AutosaverSaveUserFileJob saveUserFileJob = new(document);
+            saveUserFileJob.OnCompleted += OnUserFileSavingCompleted;
+            saveUserFileJob.OnNonCompleted += OnUserFileSavingNonCompleted;
+            StartJob(saveUserFileJob);
+        }
+        else
+        {
+            WaitForNextSave();
+        }
+    }
+
+    private void OnUserFileSavingCompleted()
+    {
+        if (isDisposed)
+            return;
+
+        lastUserFileAutosaveResult = UserFileAutosaveResult.Success;
+        lastUserFileAutosaveDateTime = DateTime.Now;
+
+        userFileSaveFailureCount = 0;
+        WaitForNextSave();
+    }
+
+    private void OnBackupSavingNonCompleted(AutosaverSaveBackupJob job, BackupAutosaveResult result)
+    {
+        if (isDisposed)
+            return;
+
+        lastBackupAutosaveResult = result;
+        lastBackupAutosaveDateTime = DateTime.Now;
+
+        switch (result)
+        {
+            case BackupAutosaveResult.Error:
+                backupSaveFailureCount++;
+                if (backupSaveFailureCount < 3)
+                    CrashHelper.SendExceptionInfo(job.Exception);
+                WaitForNextSave();
+                break;
+
+            case BackupAutosaveResult.NothingToSave:
+                WaitForNextSave();
+                break;
+
+            case BackupAutosaveResult.BlockedByUpdateableChange:
+                AutosaverAwaitUpdateableChangeEndJob waitJob = new();
+                waitJob.OnCompleted += OnAwaitUpdateableChangeCompleted;
+                StartJob(waitJob);
+                break;
+
+            default:
+                throw new ArgumentOutOfRangeException(nameof(result), result, null);
+        }
+    }
+
+    private void OnAwaitUpdateableChangeCompleted()
+    {
+        if (isDisposed)
+            return;
+
+        InitiateSaving();
+    }
+
+    private void OnUserFileSavingNonCompleted(AutosaverSaveUserFileJob job, UserFileAutosaveResult result)
+    {
+        if (isDisposed)
+            return;
+
+        lastUserFileAutosaveResult = result;
+        lastUserFileAutosaveDateTime = DateTime.Now;
+
+        switch (result)
+        {
+            case UserFileAutosaveResult.NoUserFile:
+            case UserFileAutosaveResult.ExceptionWhileSaving:
+                userFileSaveFailureCount++;
+                if (userFileSaveFailureCount < 3)
+                    CrashHelper.SendExceptionInfo(job.Exception);
+                WaitForNextSave();
+                break;
+            default:
+                throw new ArgumentOutOfRangeException(nameof(result), result, null);
+        }
+    }
+
+    public void Dispose()
+    {
+        CurrentJob?.ForceStop();
+        isDisposed = true;
+    }
+}

+ 10 - 0
src/PixiEditor/Models/DocumentModels/Autosave/IAutosaverJob.cs

@@ -0,0 +1,10 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+internal interface IAutosaverJob
+{
+    event Action OnCompleted;
+    AutosaveState CorrespondingState { get; }
+    void OnUpdateableChangeEnded();
+    void Start();
+    void ForceStop();
+}

+ 7 - 0
src/PixiEditor/Models/DocumentModels/Autosave/LastUserFileAutosaveData.cs

@@ -0,0 +1,7 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+public struct LastUserFileAutosaveData
+{
+    public DateTime Time { get; set; }
+    public UserFileAutosaveResult SaveResult { get; set; }
+}

+ 8 - 0
src/PixiEditor/Models/DocumentModels/Autosave/UserFileAutosaveResult.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Models.DocumentModels.Autosave;
+
+public enum UserFileAutosaveResult
+{
+    Success,
+    NoUserFile,
+    ExceptionWhileSaving
+}

+ 6 - 6
src/PixiEditor/Models/DocumentModels/ChangeExecutionController.cs

@@ -229,7 +229,7 @@ internal class ChangeExecutionController
             transformableExecutor.OnTransformChanged(corners);
         }
     }
-    
+
     public void TransformDraggedInlet(VecD from, VecD to)
     {
         if (currentSession is ITransformDraggedEvent transformableExecutor)
@@ -240,7 +240,7 @@ internal class ChangeExecutionController
 
     public void TransformStoppedInlet()
     {
-        if(currentSession is ITransformStoppedEvent transformStoppedEvent)
+        if (currentSession is ITransformStoppedEvent transformStoppedEvent)
         {
             transformStoppedEvent.OnTransformStopped();
         }
@@ -257,7 +257,7 @@ internal class ChangeExecutionController
         {
             transformableExecutor.OnTransformApplied();
         }
-    } 
+    }
 
     public void SettingsChangedInlet(string name, object value)
     {
@@ -269,7 +269,7 @@ internal class ChangeExecutionController
         if (currentSession is ITransformableExecutor lineOverlayExecutor)
         {
             lineOverlayExecutor.OnLineOverlayMoved(start, end);
-        } 
+        }
     }
 
     public void SelectedObjectNudgedInlet(VecI distance)
@@ -277,7 +277,7 @@ internal class ChangeExecutionController
         if (currentSession is ITransformableExecutor transformableExecutor)
         {
             transformableExecutor.OnSelectedObjectNudged(distance);
-        } 
+        }
     }
 
     public void PrimaryColorChangedInlet(Color color)
@@ -294,7 +294,7 @@ internal class ChangeExecutionController
     {
         if (currentSession is T feature)
             return feature;
-        
+
         return default;
     }
 

+ 14 - 0
src/PixiEditor/Models/DocumentPassthroughActions/MarkAsAutosaved_PassthroughAction.cs

@@ -0,0 +1,14 @@
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+
+namespace PixiEditor.Models.DocumentPassthroughActions;
+
+internal enum DocumentMarkType
+{
+    Saved,
+    Unsaved,
+    Autosaved,
+    UnAutosaved
+}
+
+internal record class MarkAsSavedAutosaved_PassthroughAction(DocumentMarkType Type) : IChangeInfo, IAction;

+ 52 - 8
src/PixiEditor/Models/ExceptionHandling/CrashReport.cs

@@ -13,6 +13,7 @@ using PixiEditor.Linux;
 #endif
 using PixiEditor.Models.AnalyticsAPI;
 using PixiEditor.Models.Commands;
+using PixiEditor.Models.DocumentModels.Autosave;
 using PixiEditor.OperatingSystem;
 using PixiEditor.Parser;
 using PixiEditor.ViewModels;
@@ -198,6 +199,8 @@ internal class CrashReport : IDisposable
             .AppendLine($"  Has shared toolbar enabled: {GetPreferenceFormatted("EnableSharedToolbar", true, false)}")
             .AppendLine($"  Right click mode: {GetPreferenceFormatted<RightClickMode>("RightClickMode", true)}")
             .AppendLine($"  Has Rich presence enabled: {GetPreferenceFormatted("EnableRichPresence", true, true)}")
+            .AppendLine(
+                $"   Autosaving Enabled: {GetPreferenceFormatted(PreferencesConstants.AutosaveEnabled, true, PreferencesConstants.AutosaveEnabledDefault)}")
             .AppendLine($"  Debug Mode enabled: {GetPreferenceFormatted("IsDebugModeEnabled", true, false)}")
             .AppendLine("\nUI:")
             .AppendLine($"  MainWindow not null: {GetFormatted(() => MainWindow.Current != null)}")
@@ -455,6 +458,11 @@ internal class CrashReport : IDisposable
         List<RecoveredPixi> recoveredDocuments = new();
 
         sessionInfo = TryGetSessionInfo();
+        if (sessionInfo == null)
+        {
+            return recoveredDocuments;
+        }
+
         if (sessionInfo?.OpenedDocuments == null)
         {
             recoveredDocuments.AddRange(
@@ -462,13 +470,21 @@ internal class CrashReport : IDisposable
                     .Where(x =>
                         x.FullName.StartsWith("Documents") &&
                         x.FullName.EndsWith(".pixi"))
-                    .Select(entry => new RecoveredPixi(null, entry)));
+                    .Select(entry => new RecoveredPixi(null, null, entry, null)));
 
             return recoveredDocuments;
         }
 
-        recoveredDocuments.AddRange(sessionInfo.OpenedDocuments.Select(path =>
-            new RecoveredPixi(path.OriginalPath, ZipFile.GetEntry($"Documents/{path.ZipName}"))));
+        foreach (var doc in sessionInfo.OpenedDocuments)
+        {
+            ZipArchiveEntry? autosaved = null;
+            if (doc.AutosavePath != null)
+            {
+                autosaved = ZipFile.GetEntry($"Autosave/{Path.GetFileName(doc.AutosavePath)}");
+            }
+
+            recoveredDocuments.Add(new RecoveredPixi(doc.OriginalPath, doc.AutosavePath, ZipFile.GetEntry($"Documents/{doc.ZipName}"), autosaved));
+        }
 
         return recoveredDocuments;
 
@@ -543,7 +559,6 @@ internal class CrashReport : IDisposable
         // Write the documents into zip
         int counter = 0;
         var originalPaths = new List<CrashedFileInfo>();
-        //TODO: Implement
         foreach (var document in documents)
         {
             try
@@ -561,7 +576,22 @@ internal class CrashReport : IDisposable
                 using Stream documentStream = archive.CreateEntry($"Documents/{nameInZip}").Open();
                 documentStream.Write(serialized);
 
-                originalPaths.Add(new CrashedFileInfo(nameInZip, document.FullFilePath));
+                originalPaths.Add(new CrashedFileInfo(nameInZip, document.FullFilePath,
+                    document.AutosaveViewModel.LastAutosavedPath));
+            }
+            catch { }
+
+            try
+            {
+                if (document.AutosaveViewModel.LastAutosavedPath != null)
+                {
+                    using var file = File.OpenRead(document.AutosaveViewModel.LastAutosavedPath);
+                    using var entry = archive
+                        .CreateEntry($"Autosave/{Path.GetFileName(document.AutosaveViewModel.LastAutosavedPath)}")
+                        .Open();
+
+                    file.CopyTo(entry);
+                }
             }
             catch { }
 
@@ -604,9 +634,11 @@ internal class CrashReport : IDisposable
 
     public class RecoveredPixi
     {
-        public string? Path { get; }
+        public string? OriginalPath { get; }
+        public string? AutosavePath { get; }
 
         public ZipArchiveEntry RecoveredEntry { get; }
+        public ZipArchiveEntry? AutosaveEntry { get; }
 
         public byte[] GetRecoveredBytes()
         {
@@ -618,10 +650,22 @@ internal class CrashReport : IDisposable
             return buffer;
         }
 
-        public RecoveredPixi(string? path, ZipArchiveEntry recoveredEntry)
+        public byte[] GetAutosaveBytes()
+        {
+            var buffer = new byte[AutosaveEntry.Length];
+            using var stream = AutosaveEntry.Open();
+
+            stream.ReadExactly(buffer);
+
+            return buffer;
+        }
+
+        public RecoveredPixi(string? originalPath, string? autosavePath, ZipArchiveEntry recoveredEntry, ZipArchiveEntry? autosaveEntry)
         {
-            Path = path;
+            OriginalPath = originalPath;
+            AutosavePath = autosavePath;
             RecoveredEntry = recoveredEntry;
+            AutosaveEntry = autosaveEntry;
         }
     }
 }

+ 3 - 1
src/PixiEditor/Models/ExceptionHandling/CrashedFileInfo.cs

@@ -5,12 +5,14 @@ public class CrashedFileInfo
     public string ZipName { get; set; }
     
     public string OriginalPath { get; set; }
+    public string AutosavePath { get; set; }
     
     public CrashedFileInfo() { }
 
-    public CrashedFileInfo(string zipName, string originalPath)
+    public CrashedFileInfo(string zipName, string originalPath, string autosavePath)
     {
         ZipName = zipName;
         OriginalPath = originalPath;
+        AutosavePath = autosavePath;
     }
 }

+ 102 - 23
src/PixiEditor/Models/Files/ImageFileType.cs

@@ -19,7 +19,7 @@ internal abstract class ImageFileType : IoFileType
 
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Image;
 
-    public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document,
+    public override async Task<SaveResult> TrySaveAsync(string pathWithExtension, DocumentViewModel document,
         ExportConfig exportConfig, ExportJob? job)
     {
         Surface finalSurface;
@@ -33,13 +33,13 @@ internal abstract class ImageFileType : IoFileType
         else
         {
             job?.Report(0, new LocalizedString("RENDERING_IMAGE"));
-            
+
             var exportSize = exportConfig.ExportSize;
             if (exportSize.X <= 0 || exportSize.Y <= 0)
             {
                 return SaveResult.UnknownError; // TODO: Add InvalidParameters error type
             }
-            
+
             var maybeBitmap = document.TryRenderWholeImage(0, exportSize);
             if (maybeBitmap.IsT0)
                 return SaveResult.ConcurrencyError;
@@ -55,9 +55,53 @@ internal abstract class ImageFileType : IoFileType
         }
 
         UniversalFileEncoder encoder = new(mappedFormat);
-        var result = await TrySaveAs(encoder, pathWithExtension, finalSurface);
+        var result = await TrySaveAsAsync(encoder, pathWithExtension, finalSurface);
         finalSurface.Dispose();
-        
+
+        job?.Report(1, new LocalizedString("FINISHED"));
+
+        return result;
+    }
+
+    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config,
+        ExportJob? job)
+    {
+        Surface finalSurface;
+        if (config.ExportAsSpriteSheet)
+        {
+            job?.Report(0, new LocalizedString("GENERATING_SPRITE_SHEET"));
+            finalSurface = GenerateSpriteSheet(document, config, job);
+            if (finalSurface == null)
+                return SaveResult.UnknownError;
+        }
+        else
+        {
+            job?.Report(0, new LocalizedString("RENDERING_IMAGE"));
+
+            var exportSize = config.ExportSize;
+            if (exportSize.X <= 0 || exportSize.Y <= 0)
+            {
+                return SaveResult.UnknownError; // TODO: Add InvalidParameters error type
+            }
+
+            var maybeBitmap = document.TryRenderWholeImage(0, exportSize);
+            if (maybeBitmap.IsT0)
+                return SaveResult.ConcurrencyError;
+
+            finalSurface = maybeBitmap.AsT1;
+        }
+
+        EncodedImageFormat mappedFormat = EncodedImageFormat;
+
+        if (mappedFormat == EncodedImageFormat.Unknown)
+        {
+            return SaveResult.UnknownError;
+        }
+
+        UniversalFileEncoder encoder = new(mappedFormat);
+        var result = TrySaveAs(encoder, pathWithExtension, finalSurface);
+        finalSurface.Dispose();
+
         job?.Report(1, new LocalizedString("FINISHED"));
 
         return result;
@@ -69,32 +113,34 @@ internal abstract class ImageFileType : IoFileType
             return null;
 
         var (rows, columns) = (config.SpriteSheetRows, config.SpriteSheetColumns);
-        
+
         rows = Math.Max(1, rows);
         columns = Math.Max(1, columns);
 
         Surface surface = new Surface(new VecI(config.ExportSize.X * columns, config.ExportSize.Y * rows));
-        
+
         job?.Report(0, new LocalizedString("RENDERING_FRAME", 0, document.AnimationDataViewModel.FramesCount));
 
         document.RenderFramesProgressive(
             (frame, index) =>
-        {
-            job?.CancellationTokenSource.Token.ThrowIfCancellationRequested();
-            
-            job?.Report(index / (double)document.AnimationDataViewModel.FramesCount, new LocalizedString("RENDERING_FRAME", index, document.AnimationDataViewModel.FramesCount));
-            int x = index % columns;
-            int y = index / columns;
-            Surface target = frame;
-            if (config.ExportSize != frame.Size)
             {
-                target =
-                    frame.ResizeNearestNeighbor(new VecI(config.ExportSize.X, config.ExportSize.Y));
-            }
-            
-            surface!.DrawingSurface.Canvas.DrawSurface(target.DrawingSurface, x * config.ExportSize.X, y * config.ExportSize.Y);
-            target.Dispose();
-        }, job?.CancellationTokenSource.Token ?? CancellationToken.None);
+                job?.CancellationTokenSource.Token.ThrowIfCancellationRequested();
+
+                job?.Report(index / (double)document.AnimationDataViewModel.FramesCount,
+                    new LocalizedString("RENDERING_FRAME", index, document.AnimationDataViewModel.FramesCount));
+                int x = index % columns;
+                int y = index / columns;
+                Surface target = frame;
+                if (config.ExportSize != frame.Size)
+                {
+                    target =
+                        frame.ResizeNearestNeighbor(new VecI(config.ExportSize.X, config.ExportSize.Y));
+                }
+
+                surface!.DrawingSurface.Canvas.DrawSurface(target.DrawingSurface, x * config.ExportSize.X,
+                    y * config.ExportSize.Y);
+                target.Dispose();
+            }, job?.CancellationTokenSource.Token ?? CancellationToken.None);
 
         return surface;
     }
@@ -102,7 +148,7 @@ internal abstract class ImageFileType : IoFileType
     /// <summary>
     /// Saves image to PNG file. Messes with the passed bitmap.
     /// </summary>
-    private static async Task<SaveResult> TrySaveAs(IFileEncoder encoder, string savePath, Surface bitmap)
+    private static async Task<SaveResult> TrySaveAsAsync(IFileEncoder encoder, string savePath, Surface bitmap)
     {
         try
         {
@@ -131,4 +177,37 @@ internal abstract class ImageFileType : IoFileType
 
         return SaveResult.Success;
     }
+
+    /// <summary>
+    /// Saves image to PNG file. Messes with the passed bitmap.
+    /// </summary>
+    private static SaveResult TrySaveAs(IFileEncoder encoder, string savePath, Surface bitmap)
+    {
+        try
+        {
+            if (!encoder.SupportsTransparency)
+                bitmap.DrawingSurface.Canvas.DrawColor(Colors.White, BlendMode.Multiply);
+
+            using var stream = new FileStream(savePath, FileMode.Create);
+            encoder.Save(stream, bitmap);
+        }
+        catch (SecurityException)
+        {
+            return SaveResult.SecurityError;
+        }
+        catch (UnauthorizedAccessException)
+        {
+            return SaveResult.SecurityError;
+        }
+        catch (IOException)
+        {
+            return SaveResult.IoError;
+        }
+        catch
+        {
+            return SaveResult.UnknownError;
+        }
+
+        return SaveResult.Success;
+    }
 }

+ 2 - 1
src/PixiEditor/Models/Files/IoFileType.cs

@@ -47,5 +47,6 @@ internal abstract class IoFileType
         return "*" + extension;
     }
 
-    public abstract Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job);
+    public abstract Task<SaveResult> TrySaveAsync(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job);
+    public abstract SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job);
 }

+ 8 - 1
src/PixiEditor/Models/Files/OtfFileType.cs

@@ -11,7 +11,14 @@ internal class OtfFileType : IoFileType
 
     public override bool CanSave => false;
 
-    public override Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job)
+    public override Task<SaveResult> TrySaveAsync(string pathWithExtension, DocumentViewModel document,
+        ExportConfig config, ExportJob? job)
+    {
+        throw new NotSupportedException("Saving OTF files is not supported.");
+    }
+
+    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config,
+        ExportJob? job)
     {
         throw new NotSupportedException("Saving OTF files is not supported.");
     }

+ 29 - 2
src/PixiEditor/Models/Files/PixiFileType.cs

@@ -14,11 +14,12 @@ internal class PixiFileType : IoFileType
     public override string DisplayName => new LocalizedString("PIXI_FILE");
     public override string[] Extensions => new[] { ".pixi" };
 
-    public override SolidColorBrush EditorColor { get;  } = new SolidColorBrush(new Color(255, 226, 1, 45));
+    public override SolidColorBrush EditorColor { get; } = new SolidColorBrush(new Color(255, 226, 1, 45));
 
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Pixi;
 
-    public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job)
+    public override async Task<SaveResult> TrySaveAsync(string pathWithExtension, DocumentViewModel document,
+        ExportConfig config, ExportJob? job)
     {
         try
         {
@@ -42,4 +43,30 @@ internal class PixiFileType : IoFileType
 
         return SaveResult.Success;
     }
+
+    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config,
+        ExportJob? job)
+    {
+        try
+        {
+            job?.Report(0, "Serializing document");
+            Parser.PixiParser.V5.Serialize(document.ToSerializable(), pathWithExtension);
+            job?.Report(1, "Document serialized");
+        }
+        catch (UnauthorizedAccessException e)
+        {
+            return SaveResult.SecurityError;
+        }
+        catch (IOException)
+        {
+            return SaveResult.IoError;
+        }
+        catch (Exception e)
+        {
+            CrashHelper.SendExceptionInfo(e);
+            return SaveResult.UnknownError;
+        }
+
+        return SaveResult.Success;
+    }
 }

+ 24 - 3
src/PixiEditor/Models/Files/SvgFileType.cs

@@ -18,12 +18,13 @@ internal class SvgFileType : IoFileType
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Vector;
     public override SolidColorBrush EditorColor { get; } = new SolidColorBrush(Color.FromRgb(0, 128, 0));
 
-    public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job)
+    public override async Task<SaveResult> TrySaveAsync(string pathWithExtension, DocumentViewModel document,
+        ExportConfig config, ExportJob? job)
     {
         job?.Report(0, string.Empty);
         SvgDocument svgDocument = document.ToSvgDocument(0, config.ExportSize, config.VectorExportConfig);
 
-        job?.Report(0.5, string.Empty); 
+        job?.Report(0.5, string.Empty);
         string xml = svgDocument.ToXml();
 
         xml = $"<!-- Created with PixiEditor (https://pixieditor.net) -->{Environment.NewLine}" + xml;
@@ -32,7 +33,27 @@ internal class SvgFileType : IoFileType
         await using FileStream fileStream = new(pathWithExtension, FileMode.Create);
         await using StreamWriter writer = new(fileStream);
         await writer.WriteAsync(xml);
-        
+
+        job?.Report(1, string.Empty);
+        return SaveResult.Success;
+    }
+
+    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config,
+        ExportJob? job)
+    {
+        job?.Report(0, string.Empty);
+        SvgDocument svgDocument = document.ToSvgDocument(0, config.ExportSize, config.VectorExportConfig);
+
+        job?.Report(0.5, string.Empty);
+        string xml = svgDocument.ToXml();
+
+        xml = $"<!-- Created with PixiEditor (https://pixieditor.net) -->{Environment.NewLine}" + xml;
+
+        job?.Report(0.75, string.Empty);
+        using FileStream fileStream = new(pathWithExtension, FileMode.Create);
+        using StreamWriter writer = new(fileStream);
+        writer.Write(xml);
+
         job?.Report(1, string.Empty);
         return SaveResult.Success;
     }

+ 8 - 1
src/PixiEditor/Models/Files/TtfFileType.cs

@@ -11,7 +11,14 @@ internal class TtfFileType : IoFileType
 
     public override bool CanSave => false;
 
-    public override Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job)
+    public override Task<SaveResult> TrySaveAsync(string pathWithExtension, DocumentViewModel document,
+        ExportConfig config, ExportJob? job)
+    {
+        throw new NotSupportedException("Saving TTF files is not supported.");
+    }
+
+    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config,
+        ExportJob? job)
     {
         throw new NotSupportedException("Saving TTF files is not supported.");
     }

+ 54 - 9
src/PixiEditor/Models/Files/VideoFileType.cs

@@ -10,16 +10,16 @@ internal abstract class VideoFileType : IoFileType
 {
     public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Video;
 
-    public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document,
+    public override async Task<SaveResult> TrySaveAsync(string pathWithExtension, DocumentViewModel document,
         ExportConfig config, ExportJob? job)
     {
         if (config.AnimationRenderer is null)
             return SaveResult.UnknownError;
 
-        List<Image> frames = new(); 
-        
+        List<Image> frames = new();
+
         job?.Report(0, new LocalizedString("WARMING_UP"));
-        
+
         int frameRendered = 0;
         int totalFrames = document.AnimationDataViewModel.FramesCount;
 
@@ -27,7 +27,8 @@ internal abstract class VideoFileType : IoFileType
         {
             job?.CancellationTokenSource.Token.ThrowIfCancellationRequested();
             frameRendered++;
-            job?.Report(((double)frameRendered / totalFrames) / 2, new LocalizedString("RENDERING_FRAME", frameRendered, totalFrames));
+            job?.Report(((double)frameRendered / totalFrames) / 2,
+                new LocalizedString("RENDERING_FRAME", frameRendered, totalFrames));
             if (config.ExportSize != surface.Size)
             {
                 return surface.ResizeNearestNeighbor(config.ExportSize);
@@ -35,20 +36,64 @@ internal abstract class VideoFileType : IoFileType
 
             return surface;
         });
-        
+
         job?.Report(0.5, new LocalizedString("RENDERING_VIDEO"));
         CancellationToken token = job?.CancellationTokenSource.Token ?? CancellationToken.None;
         var result = await config.AnimationRenderer.RenderAsync(frames, pathWithExtension, token, progress =>
         {
             job?.Report((progress / 100f) * 0.5f + 0.5, new LocalizedString("RENDERING_VIDEO"));
         });
-        
+
+        job?.Report(1, new LocalizedString("FINISHED"));
+
+        foreach (var frame in frames)
+        {
+            frame.Dispose();
+        }
+
+        return result ? SaveResult.Success : SaveResult.UnknownError;
+    }
+
+    public override SaveResult TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config,
+        ExportJob? job)
+    {
+        if (config.AnimationRenderer is null)
+            return SaveResult.UnknownError;
+
+        List<Image> frames = new();
+
+        job?.Report(0, new LocalizedString("WARMING_UP"));
+
+        int frameRendered = 0;
+        int totalFrames = document.AnimationDataViewModel.FramesCount;
+
+        document.RenderFrames(frames, surface =>
+        {
+            job?.CancellationTokenSource.Token.ThrowIfCancellationRequested();
+            frameRendered++;
+            job?.Report(((double)frameRendered / totalFrames) / 2,
+                new LocalizedString("RENDERING_FRAME", frameRendered, totalFrames));
+            if (config.ExportSize != surface.Size)
+            {
+                return surface.ResizeNearestNeighbor(config.ExportSize);
+            }
+
+            return surface;
+        });
+
+        job?.Report(0.5, new LocalizedString("RENDERING_VIDEO"));
+        CancellationToken token = job?.CancellationTokenSource.Token ?? CancellationToken.None;
+        var result = config.AnimationRenderer.Render(frames, pathWithExtension, token, progress =>
+        {
+            job?.Report((progress / 100f) * 0.5f + 0.5, new LocalizedString("RENDERING_VIDEO"));
+        });
+
         job?.Report(1, new LocalizedString("FINISHED"));
-        
+
         foreach (var frame in frames)
         {
             frame.Dispose();
-        } 
+        }
 
         return result ? SaveResult.Success : SaveResult.UnknownError;
     }

+ 5 - 0
src/PixiEditor/Models/IO/ExportConfig.cs

@@ -13,4 +13,9 @@ public class ExportConfig
    public IAnimationRenderer? AnimationRenderer { get; set; }
    
    public VectorExportConfig? VectorExportConfig { get; set; }
+
+   public ExportConfig(VecI exportSize)
+   {
+        ExportSize = exportSize;
+   }
 }

+ 30 - 2
src/PixiEditor/Models/IO/Exporter.cs

@@ -61,7 +61,8 @@ internal class Exporter
             var file = await desktop.MainWindow.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
             {
                 FileTypeChoices = SupportedFilesHelper.BuildSaveFilter(
-                    FileTypeDialogDataSet.SetKind.Any & ~FileTypeDialogDataSet.SetKind.Video), DefaultExtension = "pixi"
+                    FileTypeDialogDataSet.SetKind.Any & ~FileTypeDialogDataSet.SetKind.Video),
+                DefaultExtension = "pixi"
             });
 
             if (file is null)
@@ -117,7 +118,34 @@ internal class Exporter
 
         try
         {
-            var result = await typeFromPath.TrySave(pathWithExtension, document, exportConfig, job);
+            var result = await typeFromPath.TrySaveAsync(pathWithExtension, document, exportConfig, job);
+            job?.Finish();
+            return result;
+        }
+        catch (Exception e)
+        {
+            job?.Finish();
+            Console.WriteLine(e);
+            CrashHelper.SendExceptionInfo(e);
+            return SaveResult.UnknownError;
+        }
+    }
+
+    public static SaveResult TrySave(DocumentViewModel document, string pathWithExtension,
+        ExportConfig exportConfig, ExportJob? job)
+    {
+        string directory = Path.GetDirectoryName(pathWithExtension);
+        if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+            return SaveResult.InvalidPath;
+
+        var typeFromPath = SupportedFilesHelper.ParseImageFormat(Path.GetExtension(pathWithExtension));
+
+        if (typeFromPath is null)
+            return SaveResult.UnknownError;
+
+        try
+        {
+            var result = typeFromPath.TrySave(pathWithExtension, document, exportConfig, job);
             job?.Finish();
             return result;
         }

+ 23 - 8
src/PixiEditor/Models/IO/Paths.cs

@@ -2,16 +2,23 @@
 using System.Reflection;
 
 namespace PixiEditor.Models.IO;
+
 public static class Paths
 {
     public static string DataResourceUri { get; } = $"avares://{typeof(Paths).Assembly.GetName().Name}/Data/";
-    public static string DataFullPath { get; } = Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), "Data");
-    public static string ExtensionPackagesPath { get; } = Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), "Extensions");
+
+    public static string DataFullPath { get; } =
+        Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), "Data");
+
+    public static string ExtensionPackagesPath { get; } =
+        Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), "Extensions");
+
     public static string UserConfigPath { get; } = Path.Combine(
-        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 
+        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
         "PixiEditor", "Configs");
+
     public static string UserExtensionsPath { get; } = Path.Combine(
-        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 
+        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
         "PixiEditor", "Extensions");
 
     public static string PathToPalettesFolder { get; } = Path.Join(
@@ -20,18 +27,26 @@ public static class Paths
 
     public static string InternalResourceDataPath { get; } =
         $"avares://{Assembly.GetExecutingAssembly().GetName().Name}/Data";
-    
+
     public static string TempRenderingPath { get; } = Path.Combine(Path.GetTempPath(), "PixiEditor", "Rendering");
-    
+
     public static string TempFilesPath { get; } = Path.Combine(Path.GetTempPath(), "PixiEditor");
     public static string TempResourcesPath { get; } = Path.Combine(Path.GetTempPath(), "PixiEditor", "Resources");
 
+    /// <summary>
+    /// Path to %temp%/PixiEditor/Autosave
+    /// </summary>
+    public static string PathToUnsavedFilesFolder { get; } = Path.Join(
+        Path.GetTempPath(),
+        "PixiEditor", "Autosave");
+
     public static string ParseSpecialPathOrDefault(string path)
     {
         path = path.Replace("%appdata%", Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData));
-        path = path.Replace("%localappdata%", Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
+        path = path.Replace("%localappdata%",
+            Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
         path = path.Replace("%temp%", Path.GetTempPath());
-        
+
         return path;
     }
 }

+ 136 - 0
src/PixiEditor/ViewModels/Document/AutosaveDocumentViewModel.cs

@@ -0,0 +1,136 @@
+using ColorPicker.Models;
+using CommunityToolkit.Mvvm.ComponentModel;
+using PixiEditor.Extensions.CommonApi.UserPreferences;
+using PixiEditor.Helpers;
+using PixiEditor.Models.DocumentModels;
+using PixiEditor.Models.DocumentModels.Autosave;
+using PixiEditor.Models.IO;
+
+namespace PixiEditor.ViewModels.Document;
+
+internal class AutosaveDocumentViewModel : ObservableObject
+{
+    private AutosaveStateData? autosaveStateData;
+    public AutosaveStateData? AutosaveStateData
+    {
+        get => autosaveStateData;
+        set => SetProperty(ref autosaveStateData, value);
+    }
+
+    private bool currentDocumentAutosaveEnabled = true;
+    public bool CurrentDocumentAutosaveEnabled
+    {
+        get => currentDocumentAutosaveEnabled;
+        set
+        {
+            if (currentDocumentAutosaveEnabled == value)
+                return;
+
+            SetProperty(ref currentDocumentAutosaveEnabled, value);
+            StopOrStartAutosaverIfNecessary();
+        }
+    }
+
+    private DocumentAutosaver? autosaver;
+    private DocumentViewModel Document { get; }
+    private Guid autosaveFileGuid = Guid.NewGuid();
+    public string AutosavePath => AutosaveHelper.GetAutosavePath(autosaveFileGuid);
+
+    public string LastAutosavedPath { get; set; }
+
+    private static bool SaveUserFileEnabled => IPreferences.Current!.GetPreference(PreferencesConstants.AutosaveToDocumentPath, PreferencesConstants.AutosaveToDocumentPathDefault);
+    private static double AutosavePeriod => IPreferences.Current!.GetPreference(PreferencesConstants.AutosavePeriodMinutes, PreferencesConstants.AutosavePeriodDefault);
+    private static bool AutosaveEnabledGlobally => IPreferences.Current!.GetPreference(PreferencesConstants.AutosaveEnabled, PreferencesConstants.AutosaveEnabledDefault);
+
+    public AutosaveDocumentViewModel(DocumentViewModel document, DocumentInternalParts internals)
+    {
+        Document = document;
+        internals.ChangeController.ToolSessionFinished += (() => autosaver?.OnUpdateableChangeEnded());
+        IPreferences.Current!.AddCallback(PreferencesConstants.AutosaveEnabled, PreferenceUpdateCallback);
+        IPreferences.Current!.AddCallback(PreferencesConstants.AutosavePeriodMinutes, PreferenceUpdateCallback);
+        IPreferences.Current!.AddCallback(PreferencesConstants.AutosaveToDocumentPath, PreferenceUpdateCallback);
+        StopOrStartAutosaverIfNecessary();
+    }
+
+    private void PreferenceUpdateCallback(string str, object obj)
+    {
+        StopOrStartAutosaverIfNecessary();
+    }
+
+    private void StopAutosaver()
+    {
+        autosaver?.Dispose();
+        autosaver = null;
+        AutosaveStateData = null;
+    }
+
+    private void StopOrStartAutosaverIfNecessary()
+    {
+        StopAutosaver();
+        if (!AutosaveEnabledGlobally || !CurrentDocumentAutosaveEnabled)
+            return;
+
+        autosaver = new DocumentAutosaver(Document, TimeSpan.FromMinutes(AutosavePeriod), SaveUserFileEnabled);
+        autosaver.JobChanged += (_, _) => AutosaveStateData = autosaver.State;
+        AutosaveStateData = autosaver.State;
+    }
+
+    public bool AutosaveOnClose()
+    {
+        if (Document.AllChangesSaved)
+            return true;
+
+        try
+        {
+            string filePath = AutosavePath;
+            Directory.CreateDirectory(Directory.GetParent(filePath)!.FullName);
+            ExportConfig config = new ExportConfig(Document.SizeBindable);
+            bool success = Exporter.TrySave(Document, filePath, config, null) == SaveResult.Success;
+            if (success)
+                AddAutosaveHistoryEntry(AutosaveHistoryType.OnClose, AutosaveHistoryResult.SavedBackup);
+
+            return success;
+        }
+        catch (Exception e)
+        {
+            return false;
+        }
+    }
+
+    public void AddAutosaveHistoryEntry(AutosaveHistoryType type, AutosaveHistoryResult result)
+    {
+        List<AutosaveHistorySession>? historySessions = IPreferences.Current!.GetLocalPreference<List<AutosaveHistorySession>>(PreferencesConstants.AutosaveHistory);
+        if (historySessions is null)
+            historySessions = new();
+
+        AutosaveHistorySession currentSession;
+        if (historySessions.Count == 0 || historySessions[^1].SessionGuid != ViewModelMain.Current.CurrentSessionId)
+        {
+            currentSession = new AutosaveHistorySession(ViewModelMain.Current.CurrentSessionId, ViewModelMain.Current.LaunchDateTime);
+            historySessions.Add(currentSession);
+        }
+        else
+        {
+            currentSession = historySessions[^1];
+        }
+
+        AutosaveHistoryEntry entry = new(DateTime.Now, type, result, autosaveFileGuid);
+        currentSession.AutosaveEntries.Add(entry);
+
+        IPreferences.Current.UpdateLocalPreference(PreferencesConstants.AutosaveHistory, historySessions);
+    }
+
+    public void SetTempFileGuidAndLastSavedPath(Guid guid, string lastSavedPath)
+    {
+        autosaveFileGuid = guid;
+        LastAutosavedPath = lastSavedPath;
+    }
+
+    public void OnDocumentClosed()
+    {
+        CurrentDocumentAutosaveEnabled = false;
+        IPreferences.Current!.RemoveCallback(PreferencesConstants.AutosaveEnabled, PreferenceUpdateCallback);
+        IPreferences.Current!.RemoveCallback(PreferencesConstants.AutosavePeriodMinutes, PreferenceUpdateCallback);
+        IPreferences.Current!.RemoveCallback(PreferencesConstants.AutosaveToDocumentPath, PreferenceUpdateCallback);
+    }
+}

+ 6 - 0
src/PixiEditor/ViewModels/Document/DocumentViewModel.cs

@@ -225,6 +225,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
     IReferenceLayerHandler IDocument.ReferenceLayerHandler => ReferenceLayerViewModel;
     IAnimationHandler IDocument.AnimationHandler => AnimationDataViewModel;
     public bool UsesSrgbBlending { get; private set; }
+    public AutosaveDocumentViewModel AutosaveViewModel { get; }
 
     private DocumentViewModel()
     {
@@ -541,6 +542,11 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         OnPropertyChanged(nameof(AllChangesSaved));
     }
 
+    public void MarkAsAutosaved()
+    {
+        Internals.ActionAccumulator.AddActions(new MarkAsSavedAutosaved_PassthroughAction(DocumentMarkType.Autosaved));
+    }
+
     public void MarkAsUnsaved()
     {
         lastChangeOnSave = Guid.NewGuid();

+ 59 - 0
src/PixiEditor/ViewModels/SubViewModels/AutosaveViewModel.cs

@@ -0,0 +1,59 @@
+using PixiEditor.Extensions.CommonApi.UserPreferences;
+using PixiEditor.Helpers;
+using PixiEditor.Models;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Commands.Attributes.Evaluators;
+using PixiEditor.Models.DocumentModels.Autosave;
+using PixiEditor.Models.IO;
+using PixiEditor.ViewModels.Document;
+
+namespace PixiEditor.ViewModels.SubViewModels;
+
+internal class AutosaveViewModel(ViewModelMain owner, DocumentManagerViewModel documentManager) : SubViewModel<ViewModelMain>(owner)
+{
+    public static bool SaveSessionStateEnabled => IPreferences.Current!.GetPreference(PreferencesConstants.SaveSessionStateEnabled, PreferencesConstants.SaveSessionStateDefault);
+
+    [Command.Basic("PixiEditor.Autosave.ToggleAutosave", "AUTOSAVE_TOGGLE", "AUTOSAVE_TOGGLE_DESCRIPTION",
+        CanExecute = "PixiEditor.Autosave.HasDocumentAndAutosaveEnabled")]
+    public void ToggleAutosave()
+    {
+        var autosaveViewModel = documentManager.ActiveDocument!.AutosaveViewModel;
+
+        autosaveViewModel.CurrentDocumentAutosaveEnabled = !autosaveViewModel.CurrentDocumentAutosaveEnabled;
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Autosave.HasDocumentAndAutosaveEnabled")]
+    public bool HasDocumentAndAutosaveEnabled() =>
+        documentManager.DocumentNotNull() && IPreferences.Current!.GetPreference<bool>(PreferencesConstants.AutosaveEnabled);
+
+    public void CleanupAutosavedFilesAndHistory()
+    {
+        if (!Directory.Exists(Paths.PathToUnsavedFilesFolder))
+            return;
+
+        List<AutosaveHistorySession>? autosaveHistory = IPreferences.Current!.GetLocalPreference<List<AutosaveHistorySession>>(PreferencesConstants.AutosaveHistory);
+        if (autosaveHistory is null)
+            autosaveHistory = new();
+
+        if (autosaveHistory.Count > 3)
+            autosaveHistory = autosaveHistory[^3..];
+
+        foreach (var path in Directory.EnumerateFiles(Paths.PathToUnsavedFilesFolder))
+        {
+            try
+            {
+                Guid fileGuid = AutosaveHelper.GetAutosaveGuid(path)!.Value;
+                bool lastWriteIsOld = (DateTime.Now - File.GetLastWriteTime(path)).TotalDays > Constants.MaxAutosaveFilesLifetimeDays;
+                bool creationDateIsOld = (DateTime.Now - File.GetCreationTime(path)).TotalDays > Constants.MaxAutosaveFilesLifetimeDays;
+                bool presentInHistory = autosaveHistory.Any(sess => sess.AutosaveEntries.Any(entry => entry.TempFileGuid == fileGuid));
+
+                if (!presentInHistory && lastWriteIsOld && creationDateIsOld)
+                    File.Delete(path);
+            }
+            catch (Exception e)
+            {
+                CrashHelper.SendExceptionInfo(e);
+            }
+        }
+    }
+}

+ 47 - 4
src/PixiEditor/ViewModels/SubViewModels/FileViewModel.cs

@@ -101,8 +101,51 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
 
     private void OpenHelloTherePopup()
     {
-        var popup = new HelloTherePopup(this);
-        popup.Show();
+        List<string> args = StartupArgs.Args;
+        string file = args.FirstOrDefault(x => Importer.IsSupportedFile(x) && File.Exists(x));
+
+        var preferences = IPreferences.Current;
+
+        try
+        {
+            if (!args.Contains("--crash"))
+            {
+                var lastCrash = preferences!.GetLocalPreference<string>(PreferencesConstants.LastCrashFile);
+
+                if (lastCrash == null)
+                {
+                    MaybeReopenTempAutosavedFiles();
+                }
+                else
+                {
+                    preferences.UpdateLocalPreference<string>(PreferencesConstants.LastCrashFile, null);
+
+                    var report = CrashReport.Parse(lastCrash);
+                    OpenFromReport(report, out bool showMissingFilesDialog);
+
+                    if (showMissingFilesDialog)
+                    {
+                        CrashReportViewModel.ShowMissingFilesDialog(report);
+                    }
+                }
+            }
+        }
+        catch (Exception exc)
+        {
+            CrashHelper.SendExceptionInfo(exc);
+        }
+
+        if (file != null)
+        {
+            OpenFromPath(file);
+        }
+        else if ((Owner.DocumentManagerSubViewModel.Documents.Count == 0 && !args.Contains("--crash")) && !args.Contains("--openedInExisting"))
+        {
+            if (preferences!.GetPreference("ShowStartupWindow", true))
+            {
+                OpenHelloTherePopup();
+            }
+        }
     }
 
     private void Owner_OnStartupEvent()
@@ -412,7 +455,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         string finalPath = null;
         if (asNew || string.IsNullOrEmpty(document.FullFilePath))
         {
-            ExportConfig config = new ExportConfig() { ExportSize = document.SizeBindable };
+            ExportConfig config = new ExportConfig(document.SizeBindable);
             var result = await Exporter.TrySaveWithDialog(document, config, null);
             if (result.Result == DialogSaveResult.Cancelled)
                 return false;
@@ -427,7 +470,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         }
         else
         {
-            ExportConfig config = new ExportConfig() { ExportSize = document.SizeBindable };
+            ExportConfig config = new ExportConfig(document.SizeBindable);
             var result = await Exporter.TrySaveAsync(document, document.FullFilePath, config, null);
             if (result != SaveResult.Success)
             {

+ 2 - 0
src/PixiEditor/ViewModels/ViewModelMain.cs

@@ -87,6 +87,8 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
 
     public ActionDisplayList ActionDisplays { get; }
     public bool UserWantsToClose { get; private set; }
+    public Guid CurrentSessionId { get; } = Guid.NewGuid();
+    public DateTime LaunchDateTime { get; } = DateTime.Now;
 
     public ViewModelMain()
     {

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

@@ -24,7 +24,7 @@ internal class ExportFileDialog : CustomDialog
     
     private DocumentViewModel document;
     
-    public ExportConfig ExportConfig { get; set; } = new ExportConfig();
+    public ExportConfig ExportConfig { get; set; } = new ExportConfig(VecI.Zero);
 
     public ExportFileDialog(Window owner, VecI size, DocumentViewModel doc) : base(owner)
     {

+ 1 - 1
src/PixiEditor/Views/MainWindow.axaml.cs

@@ -104,7 +104,7 @@ internal partial class MainWindow : Window
         {
             try
             {
-                fileVM.OpenRecoveredDotPixi(document.Path, document.GetRecoveredBytes());
+                fileVM.OpenRecoveredDotPixi(document.OriginalPath, document.GetRecoveredBytes());
                 i++;
             }
             catch (Exception e)