ExportFilePopup.axaml.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. using System.ComponentModel;
  2. using System.Threading.Tasks;
  3. using Avalonia;
  4. using Avalonia.Controls;
  5. using Avalonia.Platform.Storage;
  6. using Avalonia.Threading;
  7. using ChunkyImageLib;
  8. using CommunityToolkit.Mvvm.Input;
  9. using PixiEditor.AvaloniaUI.Helpers;
  10. using PixiEditor.AvaloniaUI.Models.Files;
  11. using PixiEditor.AvaloniaUI.Models.IO;
  12. using PixiEditor.AvaloniaUI.ViewModels.Document;
  13. using PixiEditor.Extensions.Common.Localization;
  14. using PixiEditor.Numerics;
  15. using Image = PixiEditor.DrawingApi.Core.Surface.ImageData.Image;
  16. namespace PixiEditor.AvaloniaUI.Views.Dialogs;
  17. internal partial class ExportFilePopup : PixiEditorPopup
  18. {
  19. public static readonly StyledProperty<int> SaveHeightProperty =
  20. AvaloniaProperty.Register<ExportFilePopup, int>(nameof(SaveHeight), 32);
  21. public static readonly StyledProperty<int> SaveWidthProperty =
  22. AvaloniaProperty.Register<ExportFilePopup, int>(nameof(SaveWidth), 32);
  23. public static readonly StyledProperty<RelayCommand> SetBestPercentageCommandProperty =
  24. AvaloniaProperty.Register<ExportFilePopup, RelayCommand>(nameof(SetBestPercentageCommand));
  25. public static readonly StyledProperty<string?> SavePathProperty =
  26. AvaloniaProperty.Register<ExportFilePopup, string?>(nameof(SavePath), "");
  27. public static readonly StyledProperty<IoFileType> SaveFormatProperty =
  28. AvaloniaProperty.Register<ExportFilePopup, IoFileType>(nameof(SaveFormat), new PngFileType());
  29. public static readonly StyledProperty<AsyncRelayCommand> ExportCommandProperty =
  30. AvaloniaProperty.Register<ExportFilePopup, AsyncRelayCommand>(
  31. nameof(ExportCommand));
  32. public static readonly StyledProperty<string> SuggestedNameProperty =
  33. AvaloniaProperty.Register<ExportFilePopup, string>(
  34. nameof(SuggestedName));
  35. public static readonly StyledProperty<Surface> ExportPreviewProperty =
  36. AvaloniaProperty.Register<ExportFilePopup, Surface>(
  37. nameof(ExportPreview));
  38. public static readonly StyledProperty<int> SelectedExportIndexProperty =
  39. AvaloniaProperty.Register<ExportFilePopup, int>(
  40. nameof(SelectedExportIndex), 0);
  41. public static readonly StyledProperty<bool> IsGeneratingPreviewProperty =
  42. AvaloniaProperty.Register<ExportFilePopup, bool>(
  43. nameof(IsGeneratingPreview), false);
  44. public static readonly StyledProperty<int> SpriteSheetColumnsProperty =
  45. AvaloniaProperty.Register<ExportFilePopup, int>(
  46. nameof(SpriteSheetColumns), 1);
  47. public static readonly StyledProperty<int> SpriteSheetRowsProperty =
  48. AvaloniaProperty.Register<ExportFilePopup, int>(
  49. nameof(SpriteSheetRows), 1);
  50. public int SpriteSheetRows
  51. {
  52. get => GetValue(SpriteSheetRowsProperty);
  53. set => SetValue(SpriteSheetRowsProperty, value);
  54. }
  55. public int SpriteSheetColumns
  56. {
  57. get => GetValue(SpriteSheetColumnsProperty);
  58. set => SetValue(SpriteSheetColumnsProperty, value);
  59. }
  60. public bool IsGeneratingPreview
  61. {
  62. get => GetValue(IsGeneratingPreviewProperty);
  63. set => SetValue(IsGeneratingPreviewProperty, value);
  64. }
  65. public int SelectedExportIndex
  66. {
  67. get => GetValue(SelectedExportIndexProperty);
  68. set => SetValue(SelectedExportIndexProperty, value);
  69. }
  70. public int SaveWidth
  71. {
  72. get => (int)GetValue(SaveWidthProperty);
  73. set => SetValue(SaveWidthProperty, value);
  74. }
  75. public int SaveHeight
  76. {
  77. get => (int)GetValue(SaveHeightProperty);
  78. set => SetValue(SaveHeightProperty, value);
  79. }
  80. public string? SavePath
  81. {
  82. get => (string)GetValue(SavePathProperty);
  83. set => SetValue(SavePathProperty, value);
  84. }
  85. public IoFileType SaveFormat
  86. {
  87. get => (IoFileType)GetValue(SaveFormatProperty);
  88. set => SetValue(SaveFormatProperty, value);
  89. }
  90. public Surface ExportPreview
  91. {
  92. get => GetValue(ExportPreviewProperty);
  93. set => SetValue(ExportPreviewProperty, value);
  94. }
  95. public string SuggestedName
  96. {
  97. get => GetValue(SuggestedNameProperty);
  98. set => SetValue(SuggestedNameProperty, value);
  99. }
  100. public AsyncRelayCommand ExportCommand
  101. {
  102. get => GetValue(ExportCommandProperty);
  103. set => SetValue(ExportCommandProperty, value);
  104. }
  105. public RelayCommand SetBestPercentageCommand
  106. {
  107. get => (RelayCommand)GetValue(SetBestPercentageCommandProperty);
  108. set => SetValue(SetBestPercentageCommandProperty, value);
  109. }
  110. public bool IsVideoExport => SelectedExportIndex == 1;
  111. public bool IsSpriteSheetExport => SelectedExportIndex == 2;
  112. public string SizeHint => new LocalizedString("EXPORT_SIZE_HINT", GetBestPercentage());
  113. private DocumentViewModel document;
  114. private Image[] videoPreviewFrames = [];
  115. private DispatcherTimer videoPreviewTimer = new DispatcherTimer();
  116. private int activeFrame = 0;
  117. private CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
  118. static ExportFilePopup()
  119. {
  120. SaveWidthProperty.Changed.Subscribe(RerenderPreview);
  121. SaveHeightProperty.Changed.Subscribe(RerenderPreview);
  122. SpriteSheetColumnsProperty.Changed.Subscribe(RerenderPreview);
  123. SpriteSheetRowsProperty.Changed.Subscribe(RerenderPreview);
  124. SelectedExportIndexProperty.Changed.Subscribe(RerenderPreview);
  125. }
  126. public ExportFilePopup(int imageWidth, int imageHeight, DocumentViewModel document)
  127. {
  128. SaveWidth = imageWidth;
  129. SaveHeight = imageHeight;
  130. InitializeComponent();
  131. DataContext = this;
  132. Loaded += (_, _) => sizePicker.FocusWidthPicker();
  133. SaveWidth = imageWidth;
  134. SaveHeight = imageHeight;
  135. SetBestPercentageCommand = new RelayCommand(SetBestPercentage);
  136. ExportCommand = new AsyncRelayCommand(Export);
  137. this.document = document;
  138. videoPreviewTimer = new DispatcherTimer(DispatcherPriority.Normal)
  139. {
  140. Interval = TimeSpan.FromMilliseconds(1000f / document.AnimationDataViewModel.FrameRate)
  141. };
  142. videoPreviewTimer.Tick += OnVideoPreviewTimerOnTick;
  143. int framesCount = document.AnimationDataViewModel.FramesCount;
  144. var (rows, columns) = SpriteSheetUtility.CalculateGridDimensionsAuto(framesCount);
  145. SpriteSheetColumns = columns;
  146. SpriteSheetRows = rows;
  147. RenderPreview();
  148. }
  149. protected override void OnClosing(WindowClosingEventArgs e)
  150. {
  151. base.OnClosing(e);
  152. videoPreviewTimer.Stop();
  153. videoPreviewTimer.Tick -= OnVideoPreviewTimerOnTick;
  154. videoPreviewTimer = null;
  155. cancellationTokenSource.Dispose();
  156. if (ExportPreview != null)
  157. {
  158. ExportPreview.Dispose();
  159. }
  160. if (videoPreviewFrames != null)
  161. {
  162. foreach (var frame in videoPreviewFrames)
  163. {
  164. frame.Dispose();
  165. }
  166. }
  167. }
  168. private void OnVideoPreviewTimerOnTick(object? o, EventArgs eventArgs)
  169. {
  170. if (videoPreviewFrames.Length > 0)
  171. {
  172. ExportPreview.DrawingSurface.Canvas.Clear();
  173. ExportPreview.DrawingSurface.Canvas.DrawImage(videoPreviewFrames[activeFrame], 0, 0);
  174. activeFrame = (activeFrame + 1) % videoPreviewFrames.Length;
  175. }
  176. else
  177. {
  178. videoPreviewTimer.Stop();
  179. }
  180. }
  181. private void RenderPreview()
  182. {
  183. if (document == null)
  184. {
  185. return;
  186. }
  187. IsGeneratingPreview = true;
  188. videoPreviewTimer.Stop();
  189. if (IsVideoExport)
  190. {
  191. StartRenderAnimationJob();
  192. videoPreviewTimer.Interval = TimeSpan.FromMilliseconds(1000f / document.AnimationDataViewModel.FrameRate);
  193. }
  194. else
  195. {
  196. RenderImagePreview();
  197. }
  198. }
  199. private void RenderImagePreview()
  200. {
  201. if (IsSpriteSheetExport)
  202. {
  203. GenerateSpriteSheetPreview();
  204. }
  205. else
  206. {
  207. var rendered = document.TryRenderWholeImage();
  208. if (rendered.IsT1)
  209. {
  210. VecI previewSize = CalculatePreviewSize(rendered.AsT1.Size);
  211. ExportPreview = rendered.AsT1.ResizeNearestNeighbor(previewSize);
  212. rendered.AsT1.Dispose();
  213. }
  214. }
  215. IsGeneratingPreview = false;
  216. }
  217. private void GenerateSpriteSheetPreview()
  218. {
  219. int clampedColumns = Math.Max(SpriteSheetColumns, 1);
  220. int clampedRows = Math.Max(SpriteSheetRows, 1);
  221. VecI previewSize = CalculatePreviewSize(new VecI(SaveWidth * clampedColumns, SaveHeight * clampedRows));
  222. VecI singleFrameSize = new VecI(previewSize.X / Math.Max(clampedColumns, 1), previewSize.Y / Math.Max(clampedRows, 1));
  223. if (previewSize != ExportPreview.Size)
  224. {
  225. ExportPreview?.Dispose();
  226. ExportPreview = new Surface(previewSize);
  227. document.RenderFramesProgressive((frame, index) =>
  228. {
  229. int x = index % clampedColumns;
  230. int y = index / clampedColumns;
  231. var resized = frame.ResizeNearestNeighbor(new VecI(singleFrameSize.X, singleFrameSize.Y));
  232. ExportPreview!.DrawingSurface.Canvas.DrawSurface(resized.DrawingSurface, x * singleFrameSize.X, y * singleFrameSize.Y);
  233. resized.Dispose();
  234. });
  235. }
  236. }
  237. private void StartRenderAnimationJob()
  238. {
  239. if (cancellationTokenSource.Token is { CanBeCanceled: true })
  240. {
  241. cancellationTokenSource.Cancel();
  242. }
  243. cancellationTokenSource = new CancellationTokenSource();
  244. Task.Run(
  245. () =>
  246. {
  247. videoPreviewFrames = document.RenderFrames(ProcessFrame);
  248. }, cancellationTokenSource.Token).ContinueWith(_ =>
  249. {
  250. Dispatcher.UIThread.Invoke(() =>
  251. {
  252. VecI previewSize = CalculatePreviewSize(new VecI(SaveWidth, SaveHeight));
  253. if (previewSize != ExportPreview.Size)
  254. {
  255. ExportPreview?.Dispose();
  256. ExportPreview = new Surface(previewSize);
  257. }
  258. IsGeneratingPreview = false;
  259. });
  260. videoPreviewTimer.Start();
  261. });
  262. }
  263. private Surface ProcessFrame(Surface surface)
  264. {
  265. return Dispatcher.UIThread.Invoke(() =>
  266. {
  267. Surface original = surface;
  268. if (SaveWidth != surface.Size.X || SaveHeight != surface.Size.Y)
  269. {
  270. original = surface.ResizeNearestNeighbor(new VecI(SaveWidth, SaveHeight));
  271. surface.Dispose();
  272. }
  273. VecI previewSize = CalculatePreviewSize(original.Size);
  274. if (previewSize != original.Size)
  275. {
  276. var resized = original.ResizeNearestNeighbor(previewSize);
  277. original.Dispose();
  278. return resized;
  279. }
  280. return original;
  281. });
  282. }
  283. private VecI CalculatePreviewSize(VecI imageSize)
  284. {
  285. VecI maxPreviewSize = new VecI(150, 200);
  286. if (imageSize.X > maxPreviewSize.X || imageSize.Y > maxPreviewSize.Y)
  287. {
  288. float scaleX = maxPreviewSize.X / (float)imageSize.X;
  289. float scaleY = maxPreviewSize.Y / (float)imageSize.Y;
  290. float scale = Math.Min(scaleX, scaleY);
  291. int newWidth = (int)(imageSize.X * scale);
  292. int newHeight = (int)(imageSize.Y * scale);
  293. return new VecI(newWidth, newHeight);
  294. }
  295. return imageSize;
  296. }
  297. private async Task Export()
  298. {
  299. SavePath = await ChoosePath();
  300. if (SavePath != null)
  301. {
  302. Close(true);
  303. }
  304. }
  305. /// <summary>
  306. /// Command that handles Path choosing to save file
  307. /// </summary>
  308. private async Task<string?> ChoosePath()
  309. {
  310. FilePickerSaveOptions options = new FilePickerSaveOptions
  311. {
  312. Title = new LocalizedString("EXPORT_SAVE_TITLE"),
  313. SuggestedFileName = SuggestedName,
  314. SuggestedStartLocation =
  315. await GetTopLevel(this).StorageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents),
  316. FileTypeChoices =
  317. SupportedFilesHelper.BuildSaveFilter(SelectedExportIndex == 1
  318. ? FileTypeDialogDataSet.SetKind.Video
  319. : FileTypeDialogDataSet.SetKind.Image),
  320. ShowOverwritePrompt = true
  321. };
  322. IStorageFile file = await GetTopLevel(this).StorageProvider.SaveFilePickerAsync(options);
  323. if (file != null)
  324. {
  325. if (string.IsNullOrEmpty(file.Name) == false)
  326. {
  327. SaveFormat = SupportedFilesHelper.GetSaveFileType(
  328. SelectedExportIndex == 1
  329. ? FileTypeDialogDataSet.SetKind.Video
  330. : FileTypeDialogDataSet.SetKind.Image, file);
  331. if (SaveFormat == null)
  332. {
  333. return null;
  334. }
  335. string fileName = SupportedFilesHelper.FixFileExtension(file.Path.LocalPath, SaveFormat);
  336. return fileName;
  337. }
  338. }
  339. return null;
  340. }
  341. private int GetBestPercentage()
  342. {
  343. int maxDim = Math.Max(SaveWidth, SaveHeight);
  344. for (int i = 16; i >= 1; i--)
  345. {
  346. if (maxDim * i <= 1280)
  347. return i * 100;
  348. }
  349. return 100;
  350. }
  351. private void SetBestPercentage()
  352. {
  353. sizePicker.ChosenPercentageSize = GetBestPercentage();
  354. sizePicker.PercentageRb.IsChecked = true;
  355. sizePicker.PercentageLostFocus();
  356. }
  357. private static void RerenderPreview(AvaloniaPropertyChangedEventArgs e)
  358. {
  359. if (e.Sender is ExportFilePopup popup)
  360. {
  361. popup.RenderPreview();
  362. }
  363. }
  364. }