更换为wpf
This commit is contained in:
24
SpineViewer/ViewModels/AboutDialogViewModel.cs
Normal file
24
SpineViewer/ViewModels/AboutDialogViewModel.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SpineViewer.ViewModels
|
||||
{
|
||||
public class AboutDialogViewModel : ObservableObject
|
||||
{
|
||||
public string ProgramVersion => $"v{App.Version}";
|
||||
|
||||
public string ProjectUrl => "https://github.com/ww-rm/SpineViewer";
|
||||
|
||||
public RelayCommand Cmd_OpenProjectUrl => _cmd_OpenProjectUrl ??= new(() =>
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(ProjectUrl) { UseShellExecute = true });
|
||||
});
|
||||
private RelayCommand? _cmd_OpenProjectUrl;
|
||||
}
|
||||
}
|
||||
82
SpineViewer/ViewModels/DiagnosticsDialogViewModel.cs
Normal file
82
SpineViewer/ViewModels/DiagnosticsDialogViewModel.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Win32;
|
||||
using SpineViewer.Resources;
|
||||
using SpineViewer.Services;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
|
||||
namespace SpineViewer.ViewModels
|
||||
{
|
||||
public class DiagnosticsDialogViewModel : ObservableObject
|
||||
{
|
||||
public string CPU => Registry.GetValue(
|
||||
@"HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0",
|
||||
"ProcessorNameString",
|
||||
"Unknown"
|
||||
).ToString();
|
||||
|
||||
public string GPU
|
||||
{
|
||||
get
|
||||
{
|
||||
var searcher = new ManagementObjectSearcher("SELECT Name FROM Win32_VideoController");
|
||||
return string.Join("; ", searcher.Get().Cast<ManagementObject>().Select(mo => mo["Name"].ToString()));
|
||||
}
|
||||
}
|
||||
|
||||
public string Memory => $"{new Microsoft.VisualBasic.Devices.ComputerInfo().TotalPhysicalMemory / 1024f / 1024f / 1024f:F1} GB";
|
||||
|
||||
public string WindowsVersion
|
||||
{
|
||||
get
|
||||
{
|
||||
var registryKeyPath = @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion";
|
||||
var productName = Registry.GetValue(registryKeyPath, "ProductName", "Unknown") as string;
|
||||
var editionId = Registry.GetValue(registryKeyPath, "EditionID", "Unknown") as string;
|
||||
var osVersion = Environment.OSVersion.ToString();
|
||||
return $"{productName}, {editionId}, {osVersion}";
|
||||
}
|
||||
}
|
||||
|
||||
public string DotNetVersion => Environment.Version.ToString();
|
||||
|
||||
public string ProgramVersion => App.Version;
|
||||
|
||||
public string NLogVersion => typeof(NLog.Logger).Assembly.GetName().Version.ToString();
|
||||
|
||||
public string SFMLVersion => typeof(SFML.ObjectBase).Assembly.GetName().Version.ToString();
|
||||
|
||||
public string FFMpegCoreVersion => typeof(FFMpegCore.FFMpeg).Assembly.GetName().Version.ToString();
|
||||
|
||||
public string SkiaSharpVersion => typeof(SkiaSharp.SkiaSharpVersion).Assembly.GetName().Version.ToString();
|
||||
|
||||
public string HandyControlVersion => typeof(HandyControl.Themes.Theme).Assembly.GetName().Version.ToString();
|
||||
|
||||
public RelayCommand Cmd_CopyToClipboard => _cmd_CopyToClipboard ??= new(() =>
|
||||
{
|
||||
var result = string.Join(Environment.NewLine, [
|
||||
$"CPU\t{CPU}",
|
||||
$"GPU\t{GPU}",
|
||||
$"Memory\t{Memory}",
|
||||
$"WindowsVersion\t{WindowsVersion}",
|
||||
$"DotNetVersion\t{DotNetVersion}",
|
||||
$"ProgramVersion\t{ProgramVersion}",
|
||||
$"NLogVersion\t{NLogVersion}",
|
||||
$"SFMLVersion\t{SFMLVersion}",
|
||||
$"FFMpegCoreVersion\t{FFMpegCoreVersion}",
|
||||
$"SkiaSharpVersion\t{SkiaSharpVersion}",
|
||||
$"HandyControlVersion\t{HandyControlVersion}",
|
||||
]);
|
||||
Clipboard.SetText(result);
|
||||
MessagePopupService.Info(AppResource.Str_Copied);
|
||||
});
|
||||
private RelayCommand? _cmd_CopyToClipboard;
|
||||
}
|
||||
}
|
||||
411
SpineViewer/ViewModels/ExplorerListViewModel.cs
Normal file
411
SpineViewer/ViewModels/ExplorerListViewModel.cs
Normal file
@@ -0,0 +1,411 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using NLog;
|
||||
using SFML.Audio;
|
||||
using Spine;
|
||||
using Spine.Exporters;
|
||||
using SpineViewer.Extensions;
|
||||
using SpineViewer.Models;
|
||||
using SpineViewer.Resources;
|
||||
using SpineViewer.Services;
|
||||
using SpineViewer.ViewModels;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Shell;
|
||||
|
||||
namespace SpineViewer.ViewModels
|
||||
{
|
||||
public class ExplorerListViewModel : ObservableObject
|
||||
{
|
||||
/// <summary>
|
||||
/// 预览图的保存质量
|
||||
/// </summary>
|
||||
public static int PreviewQuality { get; set; } = 80;
|
||||
|
||||
/// <summary>
|
||||
/// 缩略图文件名格式字符串, 需要一个参数
|
||||
/// </summary>
|
||||
public static string PreviewFileNameFormat => ".{0}.preview.webp";
|
||||
|
||||
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
|
||||
|
||||
private readonly MainWindowViewModel _vmMain;
|
||||
|
||||
/// <summary>
|
||||
/// 当前目录路径
|
||||
/// </summary>
|
||||
private string? _currentDirectory;
|
||||
|
||||
/// <summary>
|
||||
/// 当前目录下文件项缓存
|
||||
/// </summary>
|
||||
private readonly List<ExplorerItemViewModel> _items = [];
|
||||
|
||||
public ExplorerListViewModel(MainWindowViewModel vmMain)
|
||||
{
|
||||
_vmMain = vmMain;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 筛选字符串
|
||||
/// </summary>
|
||||
public string? FilterString
|
||||
{
|
||||
get => _filterString;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _filterString, value)) return;
|
||||
if (string.IsNullOrWhiteSpace(_filterString))
|
||||
{
|
||||
_shownItems = _items.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
_shownItems = [];
|
||||
_shownItems.AddRange(_items.Where(it => it.FileName.Contains(_filterString)));
|
||||
}
|
||||
OnPropertyChanged(nameof(ShownItems));
|
||||
}
|
||||
}
|
||||
private string? _filterString;
|
||||
|
||||
/// <summary>
|
||||
/// 当前目录下的所有子项文件, 含递归目录
|
||||
/// </summary>
|
||||
public List<ExplorerItemViewModel> ShownItems => _shownItems;
|
||||
private List<ExplorerItemViewModel> _shownItems = [];
|
||||
|
||||
/// <summary>
|
||||
/// 选择项, 显示某一项的具体信息和预览图
|
||||
/// </summary>
|
||||
public ExplorerItemViewModel? SelectedItem => _selectedItem;
|
||||
private ExplorerItemViewModel? _selectedItem;
|
||||
|
||||
/// <summary>
|
||||
/// 选择文件夹命令
|
||||
/// </summary>
|
||||
public RelayCommand Cmd_ChangeCurrentDirectory => _cmd_ChangeCurrentDirectory ??= new(() =>
|
||||
{
|
||||
if (OpenFolderService.OpenFolder(out var selectedPath))
|
||||
{
|
||||
_currentDirectory = selectedPath;
|
||||
RefreshItems();
|
||||
}
|
||||
});
|
||||
private RelayCommand? _cmd_ChangeCurrentDirectory;
|
||||
|
||||
public RelayCommand Cmd_RefreshItems => _cmd_RefreshItems ??= new(RefreshItems);
|
||||
private RelayCommand? _cmd_RefreshItems;
|
||||
|
||||
/// <summary>
|
||||
/// 选中项发生变化命令
|
||||
/// </summary>
|
||||
public RelayCommand<IList?> Cmd_SelectionChanged => _cmd_SelectionChanged ??= new(args =>
|
||||
{
|
||||
if (args is null || args.Count != 1)
|
||||
{
|
||||
SetProperty(ref _selectedItem, null, nameof(SelectedItem));
|
||||
}
|
||||
else
|
||||
{
|
||||
SetProperty(ref _selectedItem, args[0] as ExplorerItemViewModel, nameof(SelectedItem));
|
||||
}
|
||||
});
|
||||
private RelayCommand<IList?>? _cmd_SelectionChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 右键菜单, 添加到模型列表
|
||||
/// </summary>
|
||||
public RelayCommand<IList?> Cmd_AddSelectedItems => _cmd_AddSelectedItems ??= new(AddSelectedItems_Execute, args => args is not null && args.Count > 0);
|
||||
private RelayCommand<IList?>? _cmd_AddSelectedItems;
|
||||
|
||||
private void AddSelectedItems_Execute(IList? args)
|
||||
{
|
||||
if (args is null || args.Count <= 0) return;
|
||||
_vmMain.SpineObjectListViewModel.AddSpineObjectFromFileList(args.Cast<ExplorerItemViewModel>().Select(m => m.FullPath).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 对参数项生成预览图
|
||||
/// </summary>
|
||||
public RelayCommand<IList?> Cmd_GeneratePreviews => _cmd_GeneratePreviews ??= new(GeneratePreview_Execute, args => args is not null && args.Count > 0);
|
||||
private RelayCommand<IList?>? _cmd_GeneratePreviews;
|
||||
|
||||
private void GeneratePreview_Execute(IList? args)
|
||||
{
|
||||
if (args is null || args.Count <= 0) return;
|
||||
|
||||
if (args.Count <= 1)
|
||||
{
|
||||
var m = (ExplorerItemViewModel)args[0];
|
||||
try
|
||||
{
|
||||
using var sp = new SpineObject(m.FullPath);
|
||||
sp.Skeleton.GetBounds(out var x, out var y, out var w, out var h);
|
||||
var bounds = new SFML.Graphics.FloatRect(x, y, w, h).GetCanvasBounds(new(510, 510), 2);
|
||||
using var exporter = new FrameExporter(512, 512)
|
||||
{
|
||||
Center = bounds.Position + bounds.Size / 2,
|
||||
Size = new(bounds.Width, -bounds.Height),
|
||||
Format = SkiaSharp.SKEncodedImageFormat.Webp,
|
||||
Quality = PreviewQuality,
|
||||
};
|
||||
exporter.Export(m.PreviewFilePath, sp);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Error("Failed to generate preview: {0}, {1}", m.PreviewFilePath, ex.Message);
|
||||
}
|
||||
_logger.LogCurrentProcessMemoryUsage();
|
||||
}
|
||||
else
|
||||
{
|
||||
ProgressService.RunAsync((pr, ct) => GeneratePreviewTask(
|
||||
args.Cast<ExplorerItemViewModel>().ToArray(), pr, ct),
|
||||
AppResource.Str_GeneratePreviewsTitle
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void GeneratePreviewTask(ExplorerItemViewModel[] models, IProgressReporter reporter, CancellationToken ct)
|
||||
{
|
||||
int totalCount = models.Length;
|
||||
int success = 0;
|
||||
int error = 0;
|
||||
|
||||
_vmMain.ProgressState = TaskbarItemProgressState.Normal;
|
||||
_vmMain.ProgressValue = 0;
|
||||
|
||||
reporter.Total = totalCount;
|
||||
reporter.Done = 0;
|
||||
reporter.ProgressText = $"[0/{totalCount}]";
|
||||
|
||||
using var exporter = new FrameExporter(512, 512)
|
||||
{
|
||||
Format = SkiaSharp.SKEncodedImageFormat.Webp,
|
||||
Quality = PreviewQuality,
|
||||
};
|
||||
for (int i = 0; i < totalCount; i++)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
var m = models[i];
|
||||
reporter.ProgressText = $"[{i}/{totalCount}] {m.FullPath}";
|
||||
|
||||
try
|
||||
{
|
||||
using var sp = new SpineObject(m.FullPath);
|
||||
sp.Skeleton.GetBounds(out var x, out var y, out var w, out var h);
|
||||
var bounds = new SFML.Graphics.FloatRect(x, y, w, h).GetCanvasBounds(new(510, 510), 2);
|
||||
exporter.Center = bounds.Position + bounds.Size / 2;
|
||||
exporter.Size = new(bounds.Width, -bounds.Height);
|
||||
exporter.Export(m.PreviewFilePath, sp);
|
||||
success++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Error("Failed to generate preview: {0}, {1}", m.PreviewFilePath, ex.Message);
|
||||
error++;
|
||||
}
|
||||
|
||||
reporter.Done = i + 1;
|
||||
reporter.ProgressText = $"[{i + 1}/{totalCount}] {m}";
|
||||
_vmMain.ProgressValue = (i + 1f) / totalCount;
|
||||
}
|
||||
_vmMain.ProgressState = TaskbarItemProgressState.None;
|
||||
|
||||
if (error > 0)
|
||||
_logger.Warn("Preview generation {0} successfully, {1} failed", success, error);
|
||||
else
|
||||
_logger.Info("{0} previews generated successfully", success);
|
||||
|
||||
_logger.LogCurrentProcessMemoryUsage();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除参数项的预览图
|
||||
/// </summary>
|
||||
public RelayCommand<IList?> Cmd_DeletePreviews => _cmd_DeletePreviews ??= new(DeletePreview_Execute, args => args is not null && args.Count > 0);
|
||||
private RelayCommand<IList?>? _cmd_DeletePreviews;
|
||||
|
||||
private void DeletePreview_Execute(IList? args)
|
||||
{
|
||||
if (args is null || args.Count <= 0) return;
|
||||
if (!MessagePopupService.Quest(string.Format(AppResource.Str_DeleteItemsQuest, args.Count))) return;
|
||||
|
||||
if (args.Count <= 10)
|
||||
{
|
||||
foreach (var m in args.Cast<ExplorerItemViewModel>())
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(m.PreviewFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Error("Failed to delete preview: {0}, {1}", m.PreviewFilePath, ex.Message);
|
||||
}
|
||||
}
|
||||
_logger.LogCurrentProcessMemoryUsage();
|
||||
}
|
||||
else
|
||||
{
|
||||
ProgressService.RunAsync((pr, ct) => DeletePreviewTask(
|
||||
args.Cast<ExplorerItemViewModel>().ToArray(), pr, ct),
|
||||
AppResource.Str_DeletePreviewsTitle
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeletePreviewTask(ExplorerItemViewModel[] models, IProgressReporter reporter, CancellationToken ct)
|
||||
{
|
||||
int totalCount = models.Length;
|
||||
int success = 0;
|
||||
int error = 0;
|
||||
|
||||
_vmMain.ProgressState = TaskbarItemProgressState.Normal;
|
||||
_vmMain.ProgressValue = 0;
|
||||
|
||||
reporter.Total = totalCount;
|
||||
reporter.Done = 0;
|
||||
reporter.ProgressText = $"[0/{totalCount}]";
|
||||
for (int i = 0; i < totalCount; i++)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
var m = models[i];
|
||||
reporter.ProgressText = $"[{i}/{totalCount}] {m.FullPath}";
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(m.PreviewFilePath);
|
||||
success++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Error("Failed to delete preview: {0}, {1}", m.PreviewFilePath, ex.Message);
|
||||
error++;
|
||||
}
|
||||
|
||||
reporter.Done = i + 1;
|
||||
reporter.ProgressText = $"[{i + 1}/{totalCount}] {m}";
|
||||
_vmMain.ProgressValue = (i + 1f) / totalCount;
|
||||
}
|
||||
_vmMain.ProgressState = TaskbarItemProgressState.None;
|
||||
|
||||
if (error > 0)
|
||||
_logger.Warn("Preview deletion {0} successfully, {1} failed", success, error);
|
||||
else
|
||||
_logger.Info("{0} previews deleted successfully", success);
|
||||
|
||||
_logger.LogCurrentProcessMemoryUsage();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新显示, 可以更新文件夹项缓存
|
||||
/// </summary>
|
||||
public void RefreshItems()
|
||||
{
|
||||
_items.Clear();
|
||||
if (Directory.Exists(_currentDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(_currentDirectory, "*.*", SearchOption.AllDirectories))
|
||||
{
|
||||
var lowerPath = file.ToLower();
|
||||
if (SpineObject.PossibleSuffixMapping.Keys.Any(lowerPath.EndsWith))
|
||||
_items.Add(new(file));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Error("Failed to enumerate files in dir: {0}, {1}", _currentDirectory, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
_shownItems = [];
|
||||
if (string.IsNullOrWhiteSpace(_filterString))
|
||||
{
|
||||
_shownItems = _items.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
_shownItems = [];
|
||||
_shownItems.AddRange(_items.Where(it => it.FileName.Contains(_filterString)));
|
||||
}
|
||||
OnPropertyChanged(nameof(ShownItems));
|
||||
}
|
||||
}
|
||||
|
||||
public class ExplorerItemViewModel : ObservableObject
|
||||
{
|
||||
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
|
||||
|
||||
public ExplorerItemViewModel(string path)
|
||||
{
|
||||
FullPath = Path.GetFullPath(path);
|
||||
FileDirectory = Path.GetDirectoryName(FullPath) ?? "";
|
||||
FileName = Path.GetFileName(FullPath);
|
||||
PreviewFilePath = Path.Combine(FileDirectory, string.Format(ExplorerListViewModel.PreviewFileNameFormat, FileName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 完整路径
|
||||
/// </summary
|
||||
public string FullPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件所处目录
|
||||
/// </summary>
|
||||
public string FileDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件名
|
||||
/// </summary>
|
||||
public string FileName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 预览图路径
|
||||
/// </summary>
|
||||
public string PreviewFilePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 预览图
|
||||
/// </summary>
|
||||
public ImageSource? PreviewImage
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
return WpfExtension.LoadWebpWithAlpha(PreviewFilePath);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Warn("Failed to load preview image for {0}, {1}", FullPath, ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
140
SpineViewer/ViewModels/Exporters/BaseExporterViewModel.cs
Normal file
140
SpineViewer/ViewModels/Exporters/BaseExporterViewModel.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using NLog;
|
||||
using SFMLRenderer;
|
||||
using Spine;
|
||||
using Spine.Exporters;
|
||||
using SpineViewer.Extensions;
|
||||
using SpineViewer.Resources;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace SpineViewer.ViewModels.Exporters
|
||||
{
|
||||
public abstract class BaseExporterViewModel: ObservableObject
|
||||
{
|
||||
protected static readonly Logger _logger = LogManager.GetCurrentClassLogger();
|
||||
|
||||
protected readonly MainWindowViewModel _vmMain;
|
||||
|
||||
protected readonly ISFMLRenderer _renderer;
|
||||
|
||||
public BaseExporterViewModel(MainWindowViewModel vmMain)
|
||||
{
|
||||
_vmMain = vmMain;
|
||||
_renderer = _vmMain.SFMLRenderer;
|
||||
}
|
||||
|
||||
public uint ResolutionX => _vmMain.SFMLRendererViewModel.ResolutionX;
|
||||
|
||||
public uint ResolutionY => _vmMain.SFMLRendererViewModel.ResolutionY;
|
||||
|
||||
/// <summary>
|
||||
/// 是否导出成单个
|
||||
/// </summary>
|
||||
public bool ExportSingle { get => _exportSingle; set => SetProperty(ref _exportSingle, value); }
|
||||
protected bool _exportSingle = false;
|
||||
|
||||
/// <summary>
|
||||
/// 输出文件夹, 如果指定则将输出内容输出到该文件夹, 如果没指定则输出到各自目录下, 导出单个时必须指定
|
||||
/// </summary>
|
||||
public string? OutputDir { get => _outputDir; set => SetProperty(ref _outputDir, value); }
|
||||
protected string? _outputDir;
|
||||
|
||||
/// <summary>
|
||||
/// 背景颜色
|
||||
/// </summary>
|
||||
public Color BackgroundColor { get => _backgroundColor; set => SetProperty(ref _backgroundColor, value); }
|
||||
protected Color _backgroundColor = Color.FromArgb(0, 0, 0, 0);
|
||||
|
||||
/// <summary>
|
||||
/// 四周边缘距离
|
||||
/// </summary>
|
||||
public uint Margin { get => _margin; set => SetProperty(ref _margin, value); }
|
||||
protected uint _margin = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 使用自动分辨率
|
||||
/// </summary>
|
||||
public bool AutoResolution { get => _autoResolution; set => SetProperty(ref _autoResolution, value); }
|
||||
protected bool _autoResolution = false;
|
||||
|
||||
/// <summary>
|
||||
/// 最大分辨率
|
||||
/// </summary>
|
||||
public uint MaxResolution { get => _maxResolution; set => SetProperty(ref _maxResolution, value); }
|
||||
protected uint _maxResolution = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// 使用提供的包围盒设置自动分辨率
|
||||
/// </summary>
|
||||
private void SetAutoResolution(BaseExporter exporter, Rect bounds)
|
||||
{
|
||||
if (!_autoResolution) return;
|
||||
|
||||
var resolution = bounds.Size.ToVector2u();
|
||||
if (resolution.X >= _maxResolution || resolution.Y >= _maxResolution)
|
||||
{
|
||||
// 缩小到最大像素限制
|
||||
var scale = Math.Min(_maxResolution / bounds.Width, _maxResolution / bounds.Height);
|
||||
resolution.X = (uint)(bounds.Width * scale);
|
||||
resolution.Y = (uint)(bounds.Height * scale);
|
||||
}
|
||||
exporter.Resolution = new(resolution.X + _margin * 2, resolution.Y + _margin * 2);
|
||||
|
||||
var viewBounds = bounds.ToFloatRect().GetCanvasBounds(resolution, _margin);
|
||||
exporter.Size = new(viewBounds.Width, -viewBounds.Height);
|
||||
exporter.Center = viewBounds.Position + viewBounds.Size / 2;
|
||||
exporter.Rotation = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用提供的模型设置导出器的自动分辨率和视区参数, 静态画面
|
||||
/// </summary>
|
||||
protected void SetAutoResolutionStatic(BaseExporter exporter, params SpineObject[] spines)
|
||||
{
|
||||
var bounds = spines[0].GetAnimationBounds();
|
||||
foreach (var sp in spines.Skip(1)) bounds.Union(sp.GetAnimationBounds());
|
||||
SetAutoResolution(exporter, bounds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用提供的模型设置导出器的自动分辨率和视区参数, 动画画面
|
||||
/// </summary>
|
||||
protected void SetAutoResolutionAnimated(BaseExporter exporter, params SpineObject[] spines)
|
||||
{
|
||||
var bounds = spines[0].GetAnimationBounds();
|
||||
foreach (var sp in spines.Skip(1)) bounds.Union(sp.GetAnimationBounds());
|
||||
SetAutoResolution(exporter, bounds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查参数是否合法并规范化参数值, 否则返回用户错误原因
|
||||
/// </summary>
|
||||
public virtual string? Validate()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_outputDir) && File.Exists(_outputDir))
|
||||
return AppResource.Str_InvalidOutputDir;
|
||||
if (!string.IsNullOrWhiteSpace(_outputDir) && !Directory.Exists(_outputDir))
|
||||
return AppResource.Str_OutputDirNotFound;
|
||||
if (_exportSingle && string.IsNullOrWhiteSpace(_outputDir))
|
||||
return AppResource.Str_OutputDirRequired;
|
||||
if (_autoResolution && _maxResolution <= 0)
|
||||
return AppResource.Str_InvalidMaxResolution;
|
||||
OutputDir = string.IsNullOrWhiteSpace(_outputDir) ? null : Path.GetFullPath(_outputDir);
|
||||
return null;
|
||||
}
|
||||
|
||||
public RelayCommand<IList?> Cmd_Export => _cmd_Export ??= new(Export_Execute, args => args is not null && args.Count > 0);
|
||||
private RelayCommand<IList?>? _cmd_Export;
|
||||
|
||||
protected abstract void Export_Execute(IList? args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using Spine.Exporters;
|
||||
using Spine;
|
||||
using SpineViewer.Extensions;
|
||||
using SpineViewer.Models;
|
||||
using SpineViewer.Resources;
|
||||
using SpineViewer.Services;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.IO;
|
||||
|
||||
namespace SpineViewer.ViewModels.Exporters
|
||||
{
|
||||
public class CustomFFmpegExporterViewModel(MainWindowViewModel vmMain) : VideoExporterViewModel(vmMain)
|
||||
{
|
||||
public string Format { get => _format; set => SetProperty(ref _format, value); }
|
||||
protected string _format = "mp4";
|
||||
|
||||
public string? Codec { get => _codec; set => SetProperty(ref _codec, value); }
|
||||
protected string? _codec;
|
||||
|
||||
public string? PixelFormat { get => _pixelFormat; set => SetProperty(ref _pixelFormat, value); }
|
||||
protected string? _pixelFormat;
|
||||
|
||||
public string? Bitrate { get => _bitrate; set => SetProperty(ref _bitrate, value); }
|
||||
protected string? _bitrate;
|
||||
|
||||
public string? Filter { get => _filter; set => SetProperty(ref _filter, value); }
|
||||
protected string? _filter;
|
||||
|
||||
public string? CustomArgs { get => _customArgs; set => SetProperty(ref _customArgs, value); }
|
||||
protected string? _customArgs;
|
||||
|
||||
private string FormatSuffix => $".{_format.ToString().ToLower()}";
|
||||
|
||||
public override string? Validate()
|
||||
{
|
||||
if (base.Validate() is string err)
|
||||
return err;
|
||||
if (string.IsNullOrWhiteSpace(_format))
|
||||
return AppResource.Str_FFmpegFormatRequired;
|
||||
return null;
|
||||
}
|
||||
|
||||
protected override void Export_Execute(IList? args)
|
||||
{
|
||||
if (args is null || args.Count <= 0) return;
|
||||
if (!ExporterDialogService.ShowCustomFFmpegExporterDialog(this)) return;
|
||||
SpineObject[] spines = args.Cast<SpineObjectModel>().Select(m => m.GetSpineObject()).ToArray();
|
||||
ProgressService.RunAsync((pr, ct) => ExportTask(spines, pr, ct), AppResource.Str_CustomFFmpegExporterTitle);
|
||||
foreach (var sp in spines) sp.Dispose();
|
||||
}
|
||||
|
||||
private void ExportTask(SpineObject[] spines, IProgressReporter pr, CancellationToken ct)
|
||||
{
|
||||
if (spines.Length <= 0) return;
|
||||
|
||||
var timestamp = DateTime.Now.ToString("yyMMddHHmmss");
|
||||
using var exporter = new CustomFFmpegExporter(_renderer.Resolution.X + _margin * 2, _renderer.Resolution.Y + _margin * 2)
|
||||
{
|
||||
BackgroundColor = new(_backgroundColor.R, _backgroundColor.G, _backgroundColor.B, _backgroundColor.A),
|
||||
Duration = _duration,
|
||||
Fps = _fps,
|
||||
KeepLast = _keepLast,
|
||||
Format = _format,
|
||||
Codec = _codec,
|
||||
PixelFormat = _pixelFormat,
|
||||
Bitrate = _bitrate,
|
||||
Filter = _filter,
|
||||
CustomArgs = _customArgs
|
||||
};
|
||||
|
||||
// 非自动分辨率则直接用预览画面的视区参数
|
||||
if (!_autoResolution)
|
||||
{
|
||||
using var view = _renderer.GetView();
|
||||
var bounds = view.GetBounds().GetCanvasBounds(_renderer.Resolution, _margin);
|
||||
exporter.Size = bounds.Size;
|
||||
exporter.Center = bounds.Position + bounds.Size / 2;
|
||||
exporter.Rotation = view.Rotation;
|
||||
}
|
||||
|
||||
if (_exportSingle)
|
||||
{
|
||||
var filename = $"ffmpeg_{timestamp}_{Guid.NewGuid().ToString()[..6]}_{_fps}{FormatSuffix}";
|
||||
var output = Path.Combine(_outputDir!, filename);
|
||||
|
||||
if (_autoResolution) SetAutoResolutionAnimated(exporter, spines);
|
||||
|
||||
exporter.ProgressReporter = (total, done, text) =>
|
||||
{
|
||||
pr.Total = total;
|
||||
pr.Done = done;
|
||||
pr.ProgressText = text;
|
||||
_vmMain.ProgressValue = pr.Done / pr.Total;
|
||||
};
|
||||
|
||||
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.Normal;
|
||||
_vmMain.ProgressValue = 0;
|
||||
try
|
||||
{
|
||||
exporter.Export(output, ct, spines);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Error("Failed to export {0}, {1}", output, ex.Message);
|
||||
}
|
||||
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.None;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 统计总帧数
|
||||
int totalFrameCount = 0;
|
||||
if (_duration > 0)
|
||||
{
|
||||
exporter.Duration = _duration;
|
||||
totalFrameCount = exporter.GetFrameCount() * spines.Length;
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var sp in spines)
|
||||
{
|
||||
exporter.Duration = sp.GetAnimationMaxDuration();
|
||||
totalFrameCount += exporter.GetFrameCount();
|
||||
}
|
||||
}
|
||||
|
||||
pr.Total = totalFrameCount;
|
||||
pr.Done = 0;
|
||||
|
||||
exporter.ProgressReporter = (total, done, text) =>
|
||||
{
|
||||
pr.Done++;
|
||||
pr.ProgressText = text;
|
||||
_vmMain.ProgressValue = pr.Done / pr.Total;
|
||||
};
|
||||
|
||||
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.Normal;
|
||||
_vmMain.ProgressValue = 0;
|
||||
foreach (var sp in spines)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
_logger.Info("Export cancelled");
|
||||
break;
|
||||
}
|
||||
|
||||
if (_autoResolution) SetAutoResolutionAnimated(exporter, sp);
|
||||
if (_duration <= 0) exporter.Duration = sp.GetAnimationMaxDuration();
|
||||
|
||||
var filename = $"{sp.Name}_{timestamp}_{Guid.NewGuid().ToString()[..6]}_{_fps}{FormatSuffix}";
|
||||
var output = Path.Combine(_outputDir ?? sp.AssetsDir, filename);
|
||||
|
||||
try
|
||||
{
|
||||
exporter.Export(output, ct, sp);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Error("Failed to export {0}, {1}", output, ex.Message);
|
||||
}
|
||||
}
|
||||
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
158
SpineViewer/ViewModels/Exporters/FFmpegVideoExporterViewModel.cs
Normal file
158
SpineViewer/ViewModels/Exporters/FFmpegVideoExporterViewModel.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using Spine.Exporters;
|
||||
using Spine;
|
||||
using SpineViewer.Extensions;
|
||||
using SpineViewer.Models;
|
||||
using SpineViewer.Resources;
|
||||
using SpineViewer.Services;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.IO;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace SpineViewer.ViewModels.Exporters
|
||||
{
|
||||
public class FFmpegVideoExporterViewModel(MainWindowViewModel vmMain) : VideoExporterViewModel(vmMain)
|
||||
{
|
||||
public ImmutableArray<FFmpegVideoExporter.VideoFormat> VideoFormats { get; } = Enum.GetValues<FFmpegVideoExporter.VideoFormat>().ToImmutableArray();
|
||||
|
||||
public FFmpegVideoExporter.VideoFormat Format { get => _format; set => SetProperty(ref _format, value); }
|
||||
protected FFmpegVideoExporter.VideoFormat _format = FFmpegVideoExporter.VideoFormat.Mp4;
|
||||
|
||||
public bool Loop { get => _loop; set => SetProperty(ref _loop, value); }
|
||||
protected bool _loop = true;
|
||||
|
||||
public int Quality { get => _quality; set => SetProperty(ref _quality, Math.Clamp(value, 0, 100)); }
|
||||
protected int _quality = 75;
|
||||
|
||||
public int Crf { get => _crf; set => SetProperty(ref _crf, Math.Clamp(value, 0, 63)); }
|
||||
protected int _crf = 23;
|
||||
|
||||
private string FormatSuffix => $".{_format.ToString().ToLower()}";
|
||||
|
||||
protected override void Export_Execute(IList? args)
|
||||
{
|
||||
if (args is null || args.Count <= 0) return;
|
||||
if (!ExporterDialogService.ShowFFmpegVideoExporterDialog(this)) return;
|
||||
SpineObject[] spines = args.Cast<SpineObjectModel>().Select(m => m.GetSpineObject()).ToArray();
|
||||
ProgressService.RunAsync((pr, ct) => ExportTask(spines, pr, ct), AppResource.Str_FFmpegVideoExporterTitle);
|
||||
foreach (var sp in spines) sp.Dispose();
|
||||
}
|
||||
|
||||
private void ExportTask(SpineObject[] spines, IProgressReporter pr, CancellationToken ct)
|
||||
{
|
||||
if (spines.Length <= 0) return;
|
||||
|
||||
var timestamp = DateTime.Now.ToString("yyMMddHHmmss");
|
||||
using var exporter = new FFmpegVideoExporter(_renderer.Resolution.X + _margin * 2, _renderer.Resolution.Y + _margin * 2)
|
||||
{
|
||||
BackgroundColor = new(_backgroundColor.R, _backgroundColor.G, _backgroundColor.B, _backgroundColor.A),
|
||||
Duration = _duration,
|
||||
Fps = _fps,
|
||||
KeepLast = _keepLast,
|
||||
Format = _format,
|
||||
Loop = _loop,
|
||||
Quality = _quality,
|
||||
Crf = _crf
|
||||
};
|
||||
|
||||
// 非自动分辨率则直接用预览画面的视区参数
|
||||
if (!_autoResolution)
|
||||
{
|
||||
using var view = _renderer.GetView();
|
||||
var bounds = view.GetBounds().GetCanvasBounds(_renderer.Resolution, _margin);
|
||||
exporter.Size = bounds.Size;
|
||||
exporter.Center = bounds.Position + bounds.Size / 2;
|
||||
exporter.Rotation = view.Rotation;
|
||||
}
|
||||
|
||||
if (_exportSingle)
|
||||
{
|
||||
var filename = $"video_{timestamp}_{Guid.NewGuid().ToString()[..6]}_{_fps}{FormatSuffix}";
|
||||
var output = Path.Combine(_outputDir!, filename);
|
||||
|
||||
if (_autoResolution) SetAutoResolutionAnimated(exporter, spines);
|
||||
|
||||
exporter.ProgressReporter = (total, done, text) =>
|
||||
{
|
||||
pr.Total = total;
|
||||
pr.Done = done;
|
||||
pr.ProgressText = text;
|
||||
_vmMain.ProgressValue = pr.Done / pr.Total;
|
||||
};
|
||||
|
||||
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.Normal;
|
||||
_vmMain.ProgressValue = 0;
|
||||
try
|
||||
{
|
||||
exporter.Export(output, ct, spines);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Error("Failed to export {0}, {1}", output, ex.Message);
|
||||
}
|
||||
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.None;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 统计总帧数
|
||||
int totalFrameCount = 0;
|
||||
if (_duration > 0)
|
||||
{
|
||||
exporter.Duration = _duration;
|
||||
totalFrameCount = exporter.GetFrameCount() * spines.Length;
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var sp in spines)
|
||||
{
|
||||
exporter.Duration = sp.GetAnimationMaxDuration();
|
||||
totalFrameCount += exporter.GetFrameCount();
|
||||
}
|
||||
}
|
||||
|
||||
pr.Total = totalFrameCount;
|
||||
pr.Done = 0;
|
||||
|
||||
exporter.ProgressReporter = (total, done, text) =>
|
||||
{
|
||||
pr.Done++;
|
||||
pr.ProgressText = text;
|
||||
_vmMain.ProgressValue = pr.Done / pr.Total;
|
||||
};
|
||||
|
||||
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.Normal;
|
||||
_vmMain.ProgressValue = 0;
|
||||
foreach (var sp in spines)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
_logger.Info("Export cancelled");
|
||||
break;
|
||||
}
|
||||
|
||||
if (_autoResolution) SetAutoResolutionAnimated(exporter, sp);
|
||||
if (_duration <= 0) exporter.Duration = sp.GetAnimationMaxDuration();
|
||||
|
||||
var filename = $"{sp.Name}_{timestamp}_{Guid.NewGuid().ToString()[..6]}_{_fps}{FormatSuffix}";
|
||||
var output = Path.Combine(_outputDir ?? sp.AssetsDir, filename);
|
||||
|
||||
try
|
||||
{
|
||||
exporter.Export(output, ct, sp);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Error("Failed to export {0}, {1}", output, ex.Message);
|
||||
}
|
||||
}
|
||||
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
129
SpineViewer/ViewModels/Exporters/FrameExporterViewModel.cs
Normal file
129
SpineViewer/ViewModels/Exporters/FrameExporterViewModel.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using SkiaSharp;
|
||||
using Spine;
|
||||
using Spine.Exporters;
|
||||
using SpineViewer.Extensions;
|
||||
using SpineViewer.Models;
|
||||
using SpineViewer.Resources;
|
||||
using SpineViewer.Services;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SpineViewer.ViewModels.Exporters
|
||||
{
|
||||
public class FrameExporterViewModel(MainWindowViewModel vmMain) : BaseExporterViewModel(vmMain)
|
||||
{
|
||||
public ImmutableArray<SKEncodedImageFormat> FrameFormats { get; } = Enum.GetValues<SKEncodedImageFormat>().ToImmutableArray();
|
||||
|
||||
public SKEncodedImageFormat Format { get => _format; set => SetProperty(ref _format, value); }
|
||||
protected SKEncodedImageFormat _format = SKEncodedImageFormat.Png;
|
||||
|
||||
public int Quality { get => _quality; set => SetProperty(ref _quality, Math.Clamp(value, 0, 100)); }
|
||||
protected int _quality = 80;
|
||||
|
||||
private string FormatSuffix
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_format == SKEncodedImageFormat.Heif) return ".jpeg";
|
||||
else if (_format == SKEncodedImageFormat.Jpegxl) return ".jpeg";
|
||||
else return $".{_format.ToString().ToLower()}";
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Export_Execute(IList? args)
|
||||
{
|
||||
if (args is null || args.Count <= 0) return;
|
||||
if (!ExporterDialogService.ShowFrameExporterDialog(this)) return;
|
||||
SpineObject[] spines = args.Cast<SpineObjectModel>().Select(m => m.GetSpineObject(true)).ToArray();
|
||||
ProgressService.RunAsync((pr, ct) => ExportTask(spines, pr, ct), AppResource.Str_FrameExporterTitle);
|
||||
foreach (var sp in spines) sp.Dispose();
|
||||
}
|
||||
|
||||
private void ExportTask(SpineObject[] spines, IProgressReporter pr, CancellationToken ct)
|
||||
{
|
||||
if (spines.Length <= 0) return;
|
||||
|
||||
var timestamp = DateTime.Now.ToString("yyMMddHHmmss");
|
||||
using var exporter = new FrameExporter(_renderer.Resolution.X + _margin * 2, _renderer.Resolution.Y + _margin * 2)
|
||||
{
|
||||
BackgroundColor = new(_backgroundColor.R, _backgroundColor.G, _backgroundColor.B, _backgroundColor.A),
|
||||
Format = _format,
|
||||
Quality = _quality
|
||||
};
|
||||
|
||||
// 非自动分辨率则直接用预览画面的视区参数
|
||||
if (!_autoResolution)
|
||||
{
|
||||
using var view = _renderer.GetView();
|
||||
var bounds = view.GetBounds().GetCanvasBounds(_renderer.Resolution, _margin);
|
||||
exporter.Size = bounds.Size;
|
||||
exporter.Center = bounds.Position + bounds.Size / 2;
|
||||
exporter.Rotation = view.Rotation;
|
||||
}
|
||||
|
||||
if (_exportSingle)
|
||||
{
|
||||
var filename = $"frame_{timestamp}_{Guid.NewGuid().ToString()[..6]}_{_quality}{FormatSuffix}";
|
||||
var output = Path.Combine(_outputDir!, filename);
|
||||
|
||||
if (_autoResolution) SetAutoResolutionStatic(exporter, spines);
|
||||
|
||||
try
|
||||
{
|
||||
exporter.Export(output, spines);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Error("Failed to export {0}, {1}", output, ex.Message);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
int total = spines.Length;
|
||||
int done = 1;
|
||||
|
||||
pr.Total = total;
|
||||
|
||||
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.Normal;
|
||||
_vmMain.ProgressValue = 0;
|
||||
foreach (var sp in spines)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
_logger.Info("Export cancelled");
|
||||
break;
|
||||
}
|
||||
|
||||
var filename = $"{sp.Name}_{timestamp}_{Guid.NewGuid().ToString()[..6]}_{_quality}{FormatSuffix}";
|
||||
var output = Path.Combine(_outputDir ?? sp.AssetsDir, filename);
|
||||
|
||||
pr.Done = done;
|
||||
pr.ProgressText = $"[{done}/{total}] {output}";
|
||||
_vmMain.ProgressValue = pr.Done / pr.Total;
|
||||
|
||||
if (_autoResolution) SetAutoResolutionStatic(exporter, sp);
|
||||
|
||||
try
|
||||
{
|
||||
exporter.Export(output, sp);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Error("Failed to export {0}, {1}", output, ex.Message);
|
||||
}
|
||||
done++;
|
||||
}
|
||||
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using Spine;
|
||||
using Spine.Exporters;
|
||||
using SpineViewer.Extensions;
|
||||
using SpineViewer.Models;
|
||||
using SpineViewer.Resources;
|
||||
using SpineViewer.Services;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SpineViewer.ViewModels.Exporters
|
||||
{
|
||||
public class FrameSequenceExporterViewModel(MainWindowViewModel vmMain) : VideoExporterViewModel(vmMain)
|
||||
{
|
||||
protected override void Export_Execute(IList? args)
|
||||
{
|
||||
if (args is null || args.Count <= 0) return;
|
||||
if (!ExporterDialogService.ShowFrameSequenceExporterDialog(this)) return;
|
||||
SpineObject[] spines = args.Cast<SpineObjectModel>().Select(m => m.GetSpineObject()).ToArray();
|
||||
ProgressService.RunAsync((pr, ct) => ExportTask(spines, pr, ct), AppResource.Str_FrameSequenceExporterTitle);
|
||||
foreach (var sp in spines) sp.Dispose();
|
||||
}
|
||||
|
||||
private void ExportTask(SpineObject[] spines, IProgressReporter pr, CancellationToken ct)
|
||||
{
|
||||
if (spines.Length <= 0) return;
|
||||
|
||||
var timestamp = DateTime.Now.ToString("yyMMddHHmmss");
|
||||
using var exporter = new FrameSequenceExporter(_renderer.Resolution.X + _margin * 2, _renderer.Resolution.Y + _margin * 2)
|
||||
{
|
||||
BackgroundColor = new(_backgroundColor.R, _backgroundColor.G, _backgroundColor.B, _backgroundColor.A),
|
||||
Duration = _duration,
|
||||
Fps = _fps,
|
||||
KeepLast = _keepLast
|
||||
};
|
||||
|
||||
// 非自动分辨率则直接用预览画面的视区参数
|
||||
if (!_autoResolution)
|
||||
{
|
||||
using var view = _renderer.GetView();
|
||||
var bounds = view.GetBounds().GetCanvasBounds(_renderer.Resolution, _margin);
|
||||
exporter.Size = bounds.Size;
|
||||
exporter.Center = bounds.Position + bounds.Size / 2;
|
||||
exporter.Rotation = view.Rotation;
|
||||
}
|
||||
|
||||
if (_exportSingle)
|
||||
{
|
||||
var folderName = $"frames_{timestamp}_{Guid.NewGuid().ToString()[..6]}_{_fps}";
|
||||
var output = Path.Combine(_outputDir!, folderName);
|
||||
|
||||
if (_autoResolution) SetAutoResolutionAnimated(exporter, spines);
|
||||
|
||||
exporter.ProgressReporter = (total, done, text) =>
|
||||
{
|
||||
pr.Total = total;
|
||||
pr.Done = done;
|
||||
pr.ProgressText = text;
|
||||
_vmMain.ProgressValue = pr.Done / pr.Total;
|
||||
};
|
||||
|
||||
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.Normal;
|
||||
_vmMain.ProgressValue = 0;
|
||||
try
|
||||
{
|
||||
exporter.Export(output, ct, spines);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Error("Failed to export {0}, {1}", output, ex.Message);
|
||||
}
|
||||
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.None;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 统计总帧数
|
||||
int totalFrameCount = 0;
|
||||
if (_duration > 0)
|
||||
{
|
||||
exporter.Duration = _duration;
|
||||
totalFrameCount = exporter.GetFrameCount() * spines.Length;
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var sp in spines)
|
||||
{
|
||||
exporter.Duration = sp.GetAnimationMaxDuration();
|
||||
totalFrameCount += exporter.GetFrameCount();
|
||||
}
|
||||
}
|
||||
|
||||
pr.Total = totalFrameCount;
|
||||
pr.Done = 0;
|
||||
|
||||
exporter.ProgressReporter = (total, done, text) =>
|
||||
{
|
||||
pr.Done++;
|
||||
pr.ProgressText = text;
|
||||
_vmMain.ProgressValue = pr.Done / pr.Total;
|
||||
};
|
||||
|
||||
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.Normal;
|
||||
_vmMain.ProgressValue = 0;
|
||||
foreach (var sp in spines)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
_logger.Info("Export cancelled");
|
||||
break;
|
||||
}
|
||||
|
||||
if (_autoResolution) SetAutoResolutionAnimated(exporter, sp);
|
||||
if (_duration <= 0) exporter.Duration = sp.GetAnimationMaxDuration();
|
||||
|
||||
var folderName = $"{sp.Name}_{timestamp}_{Guid.NewGuid().ToString()[..6]}_{_fps}";
|
||||
var output = Path.Combine(_outputDir ?? sp.AssetsDir, folderName);
|
||||
|
||||
try
|
||||
{
|
||||
exporter.Export(output, ct, sp);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Error("Failed to export {0}, {1}", output, ex.Message);
|
||||
}
|
||||
}
|
||||
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
30
SpineViewer/ViewModels/Exporters/VideoExporterViewModel.cs
Normal file
30
SpineViewer/ViewModels/Exporters/VideoExporterViewModel.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using SpineViewer.Resources;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SpineViewer.ViewModels.Exporters
|
||||
{
|
||||
public abstract class VideoExporterViewModel(MainWindowViewModel vmMain) : BaseExporterViewModel(vmMain)
|
||||
{
|
||||
public float Duration { get => _duration; set => SetProperty(ref _duration, value); }
|
||||
protected float _duration = -1;
|
||||
|
||||
public uint Fps { get => _fps; set => SetProperty(ref _fps, Math.Max(1, value)); }
|
||||
protected uint _fps = 30;
|
||||
|
||||
public bool KeepLast { get => _keepLast; set => SetProperty(ref _keepLast, value); }
|
||||
protected bool _keepLast = true;
|
||||
|
||||
public override string? Validate()
|
||||
{
|
||||
if (base.Validate() is string err)
|
||||
return err;
|
||||
if (_exportSingle && _duration <= 0)
|
||||
return AppResource.Str_InvalidDuration;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
132
SpineViewer/ViewModels/MainWindowViewModel.cs
Normal file
132
SpineViewer/ViewModels/MainWindowViewModel.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using HandyControl.Controls;
|
||||
using NLog;
|
||||
using SFMLRenderer;
|
||||
using Spine;
|
||||
using Spine.Exporters;
|
||||
using SpineViewer.Extensions;
|
||||
using SpineViewer.Models;
|
||||
using SpineViewer.Services;
|
||||
using SpineViewer.ViewModels.Exporters;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Shell;
|
||||
|
||||
namespace SpineViewer.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// MainWindow 上下文对象
|
||||
/// </summary>
|
||||
public class MainWindowViewModel : ObservableObject
|
||||
{
|
||||
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
|
||||
|
||||
public MainWindowViewModel(ISFMLRenderer sfmlRenderer)
|
||||
{
|
||||
_sfmlRenderer = sfmlRenderer;
|
||||
_explorerListViewModel = new(this);
|
||||
_spineObjectListViewModel = new(this);
|
||||
_sfmlRendererViewModel = new(this);
|
||||
}
|
||||
|
||||
public string Title => $"SpineViewer - {App.Version}";
|
||||
|
||||
/// <summary>
|
||||
/// SFML 渲染对象
|
||||
/// </summary>
|
||||
public ISFMLRenderer SFMLRenderer => _sfmlRenderer;
|
||||
private readonly ISFMLRenderer _sfmlRenderer;
|
||||
|
||||
public TaskbarItemProgressState ProgressState { get => _progressState; set => SetProperty(ref _progressState, value); }
|
||||
private TaskbarItemProgressState _progressState = TaskbarItemProgressState.None;
|
||||
|
||||
public float ProgressValue { get => _progressValue; set => SetProperty(ref _progressValue, value); }
|
||||
private float _progressValue = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 已加载的 Spine 对象
|
||||
/// </summary>
|
||||
public ObservableCollectionWithLock<SpineObjectModel> SpineObjects => _spineObjectModels;
|
||||
private readonly ObservableCollectionWithLock<SpineObjectModel> _spineObjectModels = [];
|
||||
|
||||
/// <summary>
|
||||
/// 浏览页列表 ViewModel
|
||||
/// </summary>
|
||||
public ExplorerListViewModel ExplorerListViewModel => _explorerListViewModel;
|
||||
private readonly ExplorerListViewModel _explorerListViewModel;
|
||||
|
||||
/// <summary>
|
||||
/// 模型列表 ViewModel
|
||||
/// </summary>
|
||||
public SpineObjectListViewModel SpineObjectListViewModel => _spineObjectListViewModel;
|
||||
private readonly SpineObjectListViewModel _spineObjectListViewModel;
|
||||
|
||||
/// <summary>
|
||||
/// 模型属性页 ViewModel
|
||||
/// </summary>
|
||||
public SpineObjectTabViewModel SpineObjectTabViewModel => _spineObjectTabViewModel;
|
||||
private readonly SpineObjectTabViewModel _spineObjectTabViewModel = new();
|
||||
|
||||
/// <summary>
|
||||
/// SFML 渲染 ViewModel
|
||||
/// </summary>
|
||||
public SFMLRendererViewModel SFMLRendererViewModel => _sfmlRendererViewModel;
|
||||
private readonly SFMLRendererViewModel _sfmlRendererViewModel;
|
||||
|
||||
/// <summary>
|
||||
/// 显示诊断信息对话框
|
||||
/// </summary>
|
||||
public RelayCommand Cmd_ShowDiagnosticsDialog => _cmd_ShowDiagnosticsDialog ??= new(() => { DiagnosticsDialogService.ShowDiagnosticsDialog(); });
|
||||
private RelayCommand? _cmd_ShowDiagnosticsDialog;
|
||||
|
||||
/// <summary>
|
||||
/// 显示关于对话框
|
||||
/// </summary>
|
||||
public RelayCommand Cmd_ShowAboutDialog => _cmd_ShowAboutDialog ??= new(() => { AboutDialogService.ShowAboutDialog(); });
|
||||
private RelayCommand? _cmd_ShowAboutDialog;
|
||||
|
||||
/// <summary>
|
||||
/// 调试命令
|
||||
/// </summary>
|
||||
public RelayCommand Cmd_Debug => _cmd_Debug ??= new(Debug_Execute);
|
||||
private RelayCommand? _cmd_Debug;
|
||||
|
||||
private void Debug_Execute()
|
||||
{
|
||||
#if DEBUG
|
||||
var path = @"C:\Users\ljh\Desktop\a.mp4";
|
||||
|
||||
using var exporter = new FFmpegVideoExporter(_sfmlRenderer.Resolution);
|
||||
using var view = _sfmlRenderer.GetView();
|
||||
exporter.Center = view.Center;
|
||||
exporter.Size = view.Size;
|
||||
exporter.Rotation = view.Rotation;
|
||||
exporter.Viewport = view.Viewport;
|
||||
|
||||
SpineObject[] spines;
|
||||
lock (_spineObjectModels.Lock)
|
||||
{
|
||||
spines = _spineObjectModels.Select(it => it.GetSpineObject(true)).ToArray();
|
||||
}
|
||||
|
||||
exporter.Fps = 60;
|
||||
exporter.Format = FFmpegVideoExporter.VideoFormat.Webm;
|
||||
exporter.Duration = 3;
|
||||
exporter.BackgroundColor = new(0, 0, 0, 0);
|
||||
ProgressService.RunAsync((pr, ct) =>
|
||||
{
|
||||
exporter.ProgressReporter = (total, done, text) =>
|
||||
{ pr.Total = total; pr.Done = done; pr.ProgressText = text; };
|
||||
exporter.Export(path, ct, spines);
|
||||
}, "测试一下");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
104
SpineViewer/ViewModels/ProgressDialogViewModel.cs
Normal file
104
SpineViewer/ViewModels/ProgressDialogViewModel.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using NLog;
|
||||
using SpineViewer.Resources;
|
||||
using SpineViewer.Services;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SpineViewer.ViewModels
|
||||
{
|
||||
public partial class ProgressDialogViewModel : ObservableObject, IProgressReporter, IDisposable
|
||||
{
|
||||
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly Task _task;
|
||||
|
||||
public ProgressDialogViewModel(Action<IProgressReporter, CancellationToken> work)
|
||||
{
|
||||
_task = new(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
work(this, _cts.Token);
|
||||
WorkFinished?.Invoke(this, true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Info("Work cancelled by user: {0}", _title);
|
||||
WorkFinished?.Invoke(this, false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Error("Failed to finish work: {0}, {1}", _title, ex.Message);
|
||||
WorkFinished?.Invoke(this, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void Start() => _task.Start();
|
||||
|
||||
public event EventHandler<bool>? WorkFinished;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _title = "Progress";
|
||||
|
||||
[ObservableProperty]
|
||||
private float _total = 100;
|
||||
|
||||
[ObservableProperty]
|
||||
private float _done = 0;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _progressText = "Working...";
|
||||
|
||||
public RelayCommand Cmd_Cancel => _cmd_Cancel ??= new(Cancel_Execute, Cancel_CanExecute);
|
||||
private RelayCommand? _cmd_Cancel;
|
||||
|
||||
private void Cancel_Execute()
|
||||
{
|
||||
if (!MessagePopupService.Quest(AppResource.Str_CancelQuest)) return;
|
||||
_cts.Cancel();
|
||||
Cmd_Cancel.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
private bool Cancel_CanExecute() => !_cts.IsCancellationRequested;
|
||||
|
||||
#region IDisposable 接口实现
|
||||
|
||||
private bool _disposed = false;
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (disposing)
|
||||
{
|
||||
_cts.Dispose();
|
||||
_task.Dispose();
|
||||
}
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
~ProgressDialogViewModel()
|
||||
{
|
||||
Dispose(false);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
if (_disposed)
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
405
SpineViewer/ViewModels/SFMLRendererViewModel.cs
Normal file
405
SpineViewer/ViewModels/SFMLRendererViewModel.cs
Normal file
@@ -0,0 +1,405 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using HandyControl.Expression.Shapes;
|
||||
using NLog;
|
||||
using SFMLRenderer;
|
||||
using SpineViewer.Extensions;
|
||||
using SpineViewer.Models;
|
||||
using SpineViewer.Resources;
|
||||
using SpineViewer.Services;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace SpineViewer.ViewModels
|
||||
{
|
||||
public class SFMLRendererViewModel : ObservableObject
|
||||
{
|
||||
/// <summary>
|
||||
/// 日志器
|
||||
/// </summary>
|
||||
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
|
||||
|
||||
private readonly MainWindowViewModel _vmMain;
|
||||
private readonly ObservableCollectionWithLock<SpineObjectModel> _models;
|
||||
private readonly ISFMLRenderer _renderer;
|
||||
|
||||
/// <summary>
|
||||
/// 预览画面坐标轴颜色
|
||||
/// </summary>
|
||||
private static readonly SFML.Graphics.Color _axisColor = new(220, 220, 220);
|
||||
|
||||
/// <summary>
|
||||
/// 坐标轴顶点缓冲区
|
||||
/// </summary>
|
||||
private readonly SFML.Graphics.VertexArray _axisVertices = new(SFML.Graphics.PrimitiveType.Lines, 2); // XXX: 暂时未使用 Dispose 释放
|
||||
|
||||
/// <summary>
|
||||
/// 帧间隔计时器
|
||||
/// </summary>
|
||||
private readonly SFML.System.Clock _clock = new();
|
||||
|
||||
/// <summary>
|
||||
/// 渲染任务
|
||||
/// </summary>
|
||||
private Task? _renderTask = null;
|
||||
private CancellationTokenSource? _cancelToken = null;
|
||||
|
||||
/// <summary>
|
||||
/// 快进时间量
|
||||
/// </summary>
|
||||
private float _forwardDelta = 0;
|
||||
private readonly object _forwardDeltaLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// 临时变量, 记录拖放世界源点
|
||||
/// </summary>
|
||||
private SFML.System.Vector2f? _draggingSrc = null;
|
||||
|
||||
public SFMLRendererViewModel(MainWindowViewModel vmMain)
|
||||
{
|
||||
_vmMain = vmMain;
|
||||
_models = _vmMain.SpineObjects;
|
||||
_renderer = _vmMain.SFMLRenderer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 请求选中项发生变化
|
||||
/// </summary>
|
||||
public event NotifyCollectionChangedEventHandler? RequestSelectionChanging;
|
||||
|
||||
public uint ResolutionX
|
||||
{
|
||||
get => _renderer.Resolution.X;
|
||||
set => SetProperty(_renderer.Resolution.X, value, _renderer, (r, v) => r.Resolution = new(v, r.Resolution.Y));
|
||||
}
|
||||
|
||||
public uint ResolutionY
|
||||
{
|
||||
get => _renderer.Resolution.Y;
|
||||
set => SetProperty(_renderer.Resolution.Y, value, _renderer, (r, v) => r.Resolution = new(r.Resolution.X, v));
|
||||
}
|
||||
|
||||
public float CenterX
|
||||
{
|
||||
get => _renderer.Center.X;
|
||||
set => SetProperty(_renderer.Center.X, value, _renderer, (r, v) => r.Center = new(v, r.Center.Y));
|
||||
}
|
||||
|
||||
public float CenterY
|
||||
{
|
||||
get => _renderer.Center.Y;
|
||||
set => SetProperty(_renderer.Center.Y, value, _renderer, (r, v) => r.Center = new(r.Center.X, v));
|
||||
}
|
||||
|
||||
public float Zoom
|
||||
{
|
||||
get => _renderer.Zoom;
|
||||
set => SetProperty(_renderer.Zoom, value, _renderer, (r, v) => r.Zoom = value);
|
||||
}
|
||||
|
||||
public float Rotation
|
||||
{
|
||||
get => _renderer.Rotation;
|
||||
set => SetProperty(_renderer.Rotation, value, _renderer, (r, v) => r.Rotation = value);
|
||||
}
|
||||
|
||||
public bool FlipX
|
||||
{
|
||||
get => _renderer.FlipX;
|
||||
set => SetProperty(_renderer.FlipX, value, _renderer, (r, v) => r.FlipX = value);
|
||||
}
|
||||
|
||||
public bool FlipY
|
||||
{
|
||||
get => _renderer.FlipY;
|
||||
set => SetProperty(_renderer.FlipY, value, _renderer, (r, v) => r.FlipY = value);
|
||||
}
|
||||
|
||||
public uint MaxFps
|
||||
{
|
||||
get => _renderer.MaxFps;
|
||||
set => SetProperty(_renderer.MaxFps, value, _renderer, (r, v) => r.MaxFps = value);
|
||||
}
|
||||
|
||||
public bool ShowAxis
|
||||
{
|
||||
get => _showAxis;
|
||||
set => SetProperty(ref _showAxis, value);
|
||||
}
|
||||
private bool _showAxis = true;
|
||||
|
||||
public Color BackgroundColor
|
||||
{
|
||||
get => Color.FromRgb(_backgroundColor.R, _backgroundColor.G, _backgroundColor.B);
|
||||
set => SetProperty(BackgroundColor, value, this, (m, v) => m._backgroundColor = new(value.R, value.G, value.B));
|
||||
}
|
||||
private SFML.Graphics.Color _backgroundColor = new(105, 105, 105);
|
||||
|
||||
public bool RenderSelectedOnly
|
||||
{
|
||||
get => _renderSelectedOnly;
|
||||
set => SetProperty(ref _renderSelectedOnly, value);
|
||||
}
|
||||
private bool _renderSelectedOnly = false;
|
||||
|
||||
public bool IsUpdating
|
||||
{
|
||||
get => _isUpdating;
|
||||
private set
|
||||
{
|
||||
if (value == _isUpdating) return;
|
||||
_isUpdating = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(Geo_PlayPause));
|
||||
}
|
||||
}
|
||||
private bool _isUpdating = true;
|
||||
|
||||
public RelayCommand Cmd_Stop => _cmd_Stop ??= new(() =>
|
||||
{
|
||||
IsUpdating = false;
|
||||
lock (_models) foreach (var sp in _models) sp.ResetAnimationsTime();
|
||||
});
|
||||
private RelayCommand? _cmd_Stop;
|
||||
|
||||
public RelayCommand Cmd_PlayPause => _cmd_PlayPause ??= new(() => IsUpdating = !IsUpdating);
|
||||
private RelayCommand? _cmd_PlayPause;
|
||||
|
||||
public Geometry Geo_PlayPause => _isUpdating ? AppResource.Geo_Pause : AppResource.Geo_Play;
|
||||
|
||||
public RelayCommand Cmd_Restart => _cmd_Restart ??= new(() =>
|
||||
{
|
||||
lock (_models) foreach (var sp in _models) sp.ResetAnimationsTime();
|
||||
IsUpdating = true;
|
||||
});
|
||||
private RelayCommand? _cmd_Restart;
|
||||
|
||||
public RelayCommand Cmd_ForwardStep => _cmd_ForwardStep ??= new(() =>
|
||||
{
|
||||
lock (_forwardDeltaLock) _forwardDelta += _renderer.MaxFps > 0 ? 1f / _renderer.MaxFps : 0.001f;
|
||||
});
|
||||
private RelayCommand? _cmd_ForwardStep;
|
||||
|
||||
public RelayCommand Cmd_ForwardFast => _cmd_ForwardFast ??= new(() =>
|
||||
{
|
||||
lock (_forwardDeltaLock) _forwardDelta += _renderer.MaxFps > 0 ? 10f / _renderer.MaxFps : 0.01f;
|
||||
});
|
||||
private RelayCommand? _cmd_ForwardFast;
|
||||
|
||||
public void CanvasMouseWheelScrolled(object? s, SFML.Window.MouseWheelScrollEventArgs e)
|
||||
{
|
||||
var factor = e.Delta > 0 ? 1.1f : 0.9f;
|
||||
if ((Keyboard.Modifiers & ModifierKeys.Control) == 0)
|
||||
{
|
||||
Zoom = Math.Clamp(Zoom * factor, 0.001f, 1000f); // 滚轮缩放限制一下缩放范围
|
||||
}
|
||||
else
|
||||
{
|
||||
lock (_models.Lock)
|
||||
{
|
||||
foreach (var sp in _models.Where(it => it.IsShown && it.IsSelected))
|
||||
{
|
||||
sp.Scale = Math.Clamp(sp.Scale * factor, 0.001f, 1000f); // 滚轮缩放限制一下缩放范围
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void CanvasMouseButtonPressed(object? s, SFML.Window.MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.Button == SFML.Window.Mouse.Button.Right)
|
||||
{
|
||||
_draggingSrc = _renderer.MapPixelToCoords(new(e.X, e.Y));
|
||||
}
|
||||
else if (e.Button == SFML.Window.Mouse.Button.Left && !SFML.Window.Mouse.IsButtonPressed(SFML.Window.Mouse.Button.Right))
|
||||
{
|
||||
var _src = _renderer.MapPixelToCoords(new(e.X, e.Y));
|
||||
var src = new Point(_src.X, _src.Y);
|
||||
_draggingSrc = _src;
|
||||
|
||||
lock (_models.Lock)
|
||||
{
|
||||
// 仅渲染选中模式禁止在画面里选择对象
|
||||
if (_renderSelectedOnly)
|
||||
{
|
||||
// 只在被选中的对象里判断是否有效命中
|
||||
bool hit = _models.Any(m => m.IsSelected && m.GetCurrentBounds().Contains(src));
|
||||
|
||||
// 如果没点到被选中的模型, 则不允许拖动
|
||||
if (!hit) _draggingSrc = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if ((Keyboard.Modifiers & ModifierKeys.Control) == 0)
|
||||
{
|
||||
// 没按 Ctrl 的情况下, 如果命中了已选中对象, 则就算普通命中
|
||||
bool hit = false;
|
||||
foreach (var sp in _models)
|
||||
{
|
||||
if (!sp.IsShown) continue;
|
||||
if (!sp.GetCurrentBounds().Contains(src)) continue;
|
||||
|
||||
hit = true;
|
||||
|
||||
// 如果点到了没被选中的东西, 则清空原先选中的, 改为只选中这一次点的
|
||||
if (!sp.IsSelected)
|
||||
{
|
||||
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset));
|
||||
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Add, sp));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 如果点了空白的地方, 就清空选中列表
|
||||
if (!hit) RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 按下 Ctrl 的情况就执行多选, 并且点空白处也不会清空选中, 如果点击了本来就是选中的则取消选中
|
||||
if (_models.FirstOrDefault(m => m.IsShown && m.GetCurrentBounds().Contains(src), null) is SpineObjectModel sp)
|
||||
{
|
||||
if (sp.IsSelected)
|
||||
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Remove, sp));
|
||||
else
|
||||
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Add, sp));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void CanvasMouseMove(object? s, SFML.Window.MouseMoveEventArgs e)
|
||||
{
|
||||
if (_draggingSrc is null) return;
|
||||
|
||||
var src = (SFML.System.Vector2f)_draggingSrc;
|
||||
var dst = _renderer.MapPixelToCoords(new(e.X, e.Y));
|
||||
var delta = dst - src;
|
||||
|
||||
if (SFML.Window.Mouse.IsButtonPressed(SFML.Window.Mouse.Button.Right))
|
||||
{
|
||||
_renderer.Center -= delta;
|
||||
OnPropertyChanged(nameof(CenterX));
|
||||
OnPropertyChanged(nameof(CenterY));
|
||||
}
|
||||
else if (SFML.Window.Mouse.IsButtonPressed(SFML.Window.Mouse.Button.Left))
|
||||
{
|
||||
lock (_models.Lock)
|
||||
{
|
||||
foreach (var sp in _models.Where(it => it.IsShown && it.IsSelected))
|
||||
{
|
||||
sp.X += delta.X;
|
||||
sp.Y += delta.Y;
|
||||
}
|
||||
}
|
||||
_draggingSrc = dst;
|
||||
}
|
||||
}
|
||||
|
||||
public void CanvasMouseButtonReleased(object? s, SFML.Window.MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.Button == SFML.Window.Mouse.Button.Right)
|
||||
{
|
||||
_draggingSrc = null;
|
||||
}
|
||||
else if (e.Button == SFML.Window.Mouse.Button.Left && !SFML.Window.Mouse.IsButtonPressed(SFML.Window.Mouse.Button.Right))
|
||||
{
|
||||
_draggingSrc = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void StartRender()
|
||||
{
|
||||
if (_renderTask is not null) return;
|
||||
_cancelToken = new();
|
||||
_renderTask = new Task(RenderTask, _cancelToken.Token, TaskCreationOptions.LongRunning);
|
||||
_renderTask.Start();
|
||||
IsUpdating = true;
|
||||
}
|
||||
|
||||
public void StopRender()
|
||||
{
|
||||
IsUpdating = false;
|
||||
if (_renderTask is null || _cancelToken is null) return;
|
||||
_cancelToken.Cancel();
|
||||
_renderTask.Wait();
|
||||
_cancelToken = null;
|
||||
_renderTask = null;
|
||||
}
|
||||
|
||||
private void RenderTask()
|
||||
{
|
||||
try
|
||||
{
|
||||
_renderer.SetActive(true);
|
||||
|
||||
float delta;
|
||||
while (!_cancelToken?.IsCancellationRequested ?? false)
|
||||
{
|
||||
delta = _clock.ElapsedTime.AsSeconds();
|
||||
_clock.Restart();
|
||||
|
||||
// 停止更新的时候只是时间不前进, 但是坐标变换还是要更新, 否则无法移动对象
|
||||
if (!_isUpdating) delta = 0;
|
||||
|
||||
// 加上要快进的量
|
||||
lock (_forwardDeltaLock)
|
||||
{
|
||||
delta += _forwardDelta;
|
||||
_forwardDelta = 0;
|
||||
}
|
||||
|
||||
_renderer.Clear(_backgroundColor);
|
||||
|
||||
if (_showAxis)
|
||||
{
|
||||
// 画一个很长的坐标轴, 用 1e9 比较合适
|
||||
_axisVertices[0] = new(new(-1e9f, 0), _axisColor);
|
||||
_axisVertices[1] = new(new(1e9f, 0), _axisColor);
|
||||
_renderer.Draw(_axisVertices);
|
||||
_axisVertices[0] = new(new(0, -1e9f), _axisColor);
|
||||
_axisVertices[1] = new(new(0, 1e9f), _axisColor);
|
||||
_renderer.Draw(_axisVertices);
|
||||
}
|
||||
|
||||
// 渲染 Spine
|
||||
lock (_models.Lock)
|
||||
{
|
||||
foreach (var sp in _models.Where(sp => sp.IsShown && (!_renderSelectedOnly || sp.IsSelected)).Reverse())
|
||||
{
|
||||
if (_cancelToken?.IsCancellationRequested ?? true) break; // 提前中止
|
||||
|
||||
sp.Update(0); // 避免物理效果出现问题
|
||||
sp.Update(delta);
|
||||
|
||||
sp.EnableDebug = true;
|
||||
_renderer.Draw(sp);
|
||||
sp.EnableDebug = false;
|
||||
}
|
||||
}
|
||||
|
||||
_renderer.Display();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Fatal("Render task stopped, {0}", ex.Message);
|
||||
MessagePopupService.Error(ex.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
_renderer.SetActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
356
SpineViewer/ViewModels/SpineObjectListViewModel.cs
Normal file
356
SpineViewer/ViewModels/SpineObjectListViewModel.cs
Normal file
@@ -0,0 +1,356 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using NLog;
|
||||
using Spine;
|
||||
using SpineViewer.Extensions;
|
||||
using SpineViewer.Models;
|
||||
using SpineViewer.Resources;
|
||||
using SpineViewer.Services;
|
||||
using SpineViewer.ViewModels.Exporters;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Shell;
|
||||
|
||||
namespace SpineViewer.ViewModels
|
||||
{
|
||||
public class SpineObjectListViewModel : ObservableObject
|
||||
{
|
||||
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
|
||||
|
||||
/// <summary>
|
||||
/// 主窗口视图模型引用
|
||||
/// </summary>
|
||||
private readonly MainWindowViewModel _vmMain;
|
||||
|
||||
/// <summary>
|
||||
/// 临时对象, 存储复制的模型参数
|
||||
/// </summary>
|
||||
private SpineObjectConfigModel? _copiedSpineObjectConfigModel = null;
|
||||
|
||||
public SpineObjectListViewModel(MainWindowViewModel mainViewModel)
|
||||
{
|
||||
_vmMain = mainViewModel;
|
||||
_spineObjectModels = _vmMain.SpineObjects; // 缓存对象
|
||||
|
||||
_frameExporterViewModel = new(_vmMain);
|
||||
_frameSequenceExporterViewModel = new(_vmMain);
|
||||
_ffmpegVideoExporterViewModel = new(_vmMain);
|
||||
_customFFmpegExporterViewModel = new(_vmMain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单帧导出 ViewModel
|
||||
/// </summary>
|
||||
public FrameExporterViewModel FrameExporterViewModel => _frameExporterViewModel;
|
||||
private readonly FrameExporterViewModel _frameExporterViewModel;
|
||||
|
||||
/// <summary>
|
||||
/// 帧序列 ViewModel
|
||||
/// </summary>
|
||||
public FrameSequenceExporterViewModel FrameSequenceExporterViewModel => _frameSequenceExporterViewModel;
|
||||
private readonly FrameSequenceExporterViewModel _frameSequenceExporterViewModel;
|
||||
|
||||
/// <summary>
|
||||
/// 动图/视频 ViewModel
|
||||
/// </summary>
|
||||
public FFmpegVideoExporterViewModel FFmpegVideoExporterViewModel => _ffmpegVideoExporterViewModel;
|
||||
private readonly FFmpegVideoExporterViewModel _ffmpegVideoExporterViewModel;
|
||||
|
||||
/// <summary>
|
||||
/// 动图/视频 ViewModel
|
||||
/// </summary>
|
||||
public CustomFFmpegExporterViewModel CustomFFmpegExporterViewModel => _customFFmpegExporterViewModel;
|
||||
private readonly CustomFFmpegExporterViewModel _customFFmpegExporterViewModel;
|
||||
|
||||
/// <summary>
|
||||
/// 已加载的 Spine 对象
|
||||
/// </summary>
|
||||
public ObservableCollectionWithLock<SpineObjectModel> SpineObjects => _spineObjectModels;
|
||||
private readonly ObservableCollectionWithLock<SpineObjectModel> _spineObjectModels;
|
||||
|
||||
/// <summary>
|
||||
/// 列表视图选中项发生改变时同步内部模型列表状态
|
||||
/// </summary>
|
||||
public RelayCommand<IList?> Cmd_ListViewSelectionChanged => _cmd_ListViewSelectionChanged ??= new(ListViewSelectionChanged_Execute);
|
||||
private RelayCommand<IList?>? _cmd_ListViewSelectionChanged;
|
||||
|
||||
private void ListViewSelectionChanged_Execute(IList? args)
|
||||
{
|
||||
if (args is null) return;
|
||||
lock (_spineObjectModels.Lock)
|
||||
{
|
||||
var selectedItems = args.Cast<SpineObjectModel>().ToArray();
|
||||
foreach (var it in _spineObjectModels.Except(selectedItems)) it.IsSelected = false;
|
||||
foreach (var it in selectedItems) it.IsSelected = true;
|
||||
_vmMain.SpineObjectTabViewModel.SelectedObjects = selectedItems;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 弹窗添加单模型命令
|
||||
/// </summary>
|
||||
public RelayCommand Cmd_AddSpineObject => _cmd_AddSpineObject ??= new(AddSpineObject_Execute);
|
||||
private RelayCommand? _cmd_AddSpineObject;
|
||||
|
||||
private void AddSpineObject_Execute()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除给定模型
|
||||
/// </summary>
|
||||
public RelayCommand<IList?> Cmd_RemoveSpineObject => _cmd_RemoveSpineObject ??= new(RemoveSpineObject_Execute, RemoveSpineObject_CanExecute);
|
||||
private RelayCommand<IList?>? _cmd_RemoveSpineObject;
|
||||
|
||||
private void RemoveSpineObject_Execute(IList? args)
|
||||
{
|
||||
if (args is null) return;
|
||||
|
||||
if (args.Count > 1)
|
||||
{
|
||||
if (!MessagePopupService.Quest(string.Format(AppResource.Str_RemoveItemsQuest, args.Count)))
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_spineObjectModels.Lock)
|
||||
{
|
||||
// XXX: 这里必须要浅拷贝一次, 不能直接对会被修改的绑定数据 args 进行 foreach 遍历
|
||||
foreach (var sp in args.Cast<SpineObjectModel>().ToArray())
|
||||
{
|
||||
_spineObjectModels.Remove(sp);
|
||||
sp.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool RemoveSpineObject_CanExecute(IList? args)
|
||||
{
|
||||
if (args is null) return false;
|
||||
if (args.Count <= 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模型上移一位
|
||||
/// </summary>
|
||||
public RelayCommand<IList?> Cmd_MoveUpSpineObject => _cmd_MoveUpSpineObject ??= new(MoveUpSpineObject_Execute, MoveUpSpineObject_CanExecute);
|
||||
private RelayCommand<IList?>? _cmd_MoveUpSpineObject;
|
||||
|
||||
private void MoveUpSpineObject_Execute(IList? args)
|
||||
{
|
||||
if (args is null) return;
|
||||
if (args.Count != 1) return;
|
||||
var sp = (SpineObjectModel)args[0];
|
||||
lock (_spineObjectModels.Lock)
|
||||
{
|
||||
var idx = _spineObjectModels.IndexOf(sp);
|
||||
if (idx <= 0) return;
|
||||
_spineObjectModels.Move(idx, idx - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private bool MoveUpSpineObject_CanExecute(IList? args)
|
||||
{
|
||||
if (args is null) return false;
|
||||
if (args.Count != 1) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模型下移一位
|
||||
/// </summary>
|
||||
public RelayCommand<IList?> Cmd_MoveDownSpineObject => _cmd_MoveDownSpineObject ??= new(MoveDownSpineObject_Execute, MoveDownSpineObject_CanExecute);
|
||||
private RelayCommand<IList?>? _cmd_MoveDownSpineObject;
|
||||
|
||||
private void MoveDownSpineObject_Execute(IList? args)
|
||||
{
|
||||
if (args is null) return;
|
||||
if (args.Count != 1) return;
|
||||
var sp = (SpineObjectModel)args[0];
|
||||
lock (_spineObjectModels.Lock)
|
||||
{
|
||||
var idx = _spineObjectModels.IndexOf(sp);
|
||||
if (idx < 0 || idx >= _spineObjectModels.Count - 1) return;
|
||||
_spineObjectModels.Move(idx, idx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private bool MoveDownSpineObject_CanExecute(IList? args)
|
||||
{
|
||||
if (args is null) return false;
|
||||
if (args.Count != 1) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从剪贴板文件列表添加模型
|
||||
/// </summary>
|
||||
public RelayCommand Cmd_AddSpineObjectFromClipboard => _cmd_AddSpineObjectFromClipboard ??= new(AddSpineObjectFromClipboard_Execute);
|
||||
private RelayCommand? _cmd_AddSpineObjectFromClipboard;
|
||||
|
||||
private void AddSpineObjectFromClipboard_Execute()
|
||||
{
|
||||
if (!Clipboard.ContainsFileDropList()) return;
|
||||
AddSpineObjectFromFileList(Clipboard.GetFileDropList().Cast<string>().ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制模型参数
|
||||
/// </summary>
|
||||
public RelayCommand<IList?> Cmd_CopySpineObjectConfig => _cmd_CopySpineObjectConfig ??= new(CopySpineObjectConfig_Execute, CopySpineObjectConfig_CanExecute);
|
||||
private RelayCommand<IList?>? _cmd_CopySpineObjectConfig;
|
||||
|
||||
private void CopySpineObjectConfig_Execute(IList? args)
|
||||
{
|
||||
if (args is null) return;
|
||||
if (args.Count != 1) return;
|
||||
var sp = (SpineObjectModel)args[0];
|
||||
_copiedSpineObjectConfigModel = sp.Dump();
|
||||
_logger.Info("Copy config from model: {0}", sp.Name);
|
||||
}
|
||||
|
||||
private bool CopySpineObjectConfig_CanExecute(IList? args)
|
||||
{
|
||||
if (args is null) return false;
|
||||
if (args.Count != 1) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用复制的模型参数
|
||||
/// </summary>
|
||||
public RelayCommand<IList?> Cmd_ApplySpineObjectConfig => _cmd_ApplySpineObjectConfig ??= new(ApplySpineObjectConfig_Execute, ApplySpineObjectConfig_CanExecute);
|
||||
private RelayCommand<IList?>? _cmd_ApplySpineObjectConfig;
|
||||
|
||||
private void ApplySpineObjectConfig_Execute(IList? args)
|
||||
{
|
||||
if (_copiedSpineObjectConfigModel is null) return;
|
||||
if (args is null) return;
|
||||
if (args.Count <= 0) return;
|
||||
foreach (SpineObjectModel sp in args)
|
||||
{
|
||||
sp.Load(_copiedSpineObjectConfigModel);
|
||||
_logger.Info("Apply config to model: {0}", sp.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ApplySpineObjectConfig_CanExecute(IList? args)
|
||||
{
|
||||
if (_copiedSpineObjectConfigModel is null) return false;
|
||||
if (args is null) return false;
|
||||
if (args.Count <= 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从路径列表添加对象
|
||||
/// </summary>
|
||||
/// <param name="paths">可以是文件和文件夹</param>
|
||||
public void AddSpineObjectFromFileList(IEnumerable<string> paths)
|
||||
{
|
||||
List<string> validPaths = [];
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var lowerPath = path.ToLower();
|
||||
if (SpineObject.PossibleSuffixMapping.Keys.Any(lowerPath.EndsWith))
|
||||
validPaths.Add(path);
|
||||
}
|
||||
else if (Directory.Exists(path))
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(path, "*.*", SearchOption.AllDirectories))
|
||||
{
|
||||
var lowerPath = file.ToLower();
|
||||
if (SpineObject.PossibleSuffixMapping.Keys.Any(lowerPath.EndsWith))
|
||||
validPaths.Add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validPaths.Count > 1)
|
||||
{
|
||||
if (validPaths.Count > 100)
|
||||
{
|
||||
if (!MessagePopupService.Quest(string.Format(AppResource.Str_TooManyItemsToAddQuest, validPaths.Count)))
|
||||
return;
|
||||
}
|
||||
ProgressService.RunAsync((pr, ct) => AddSpineObjectsTask(
|
||||
validPaths.ToArray(), pr, ct),
|
||||
AppResource.Str_AddSpineObjectsTitle
|
||||
);
|
||||
}
|
||||
else if (validPaths.Count > 0)
|
||||
{
|
||||
var skelPath = validPaths[0];
|
||||
try
|
||||
{
|
||||
var sp = new SpineObjectModel(skelPath);
|
||||
lock (_spineObjectModels.Lock) _spineObjectModels.Add(sp);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Error("Failed to load: {0}, {1}", skelPath, ex.Message);
|
||||
}
|
||||
_logger.LogCurrentProcessMemoryUsage();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于后台添加模型的任务方法
|
||||
/// </summary>
|
||||
private void AddSpineObjectsTask(string[] paths, IProgressReporter reporter, CancellationToken ct)
|
||||
{
|
||||
int totalCount = paths.Length;
|
||||
int success = 0;
|
||||
int error = 0;
|
||||
|
||||
_vmMain.ProgressState = TaskbarItemProgressState.Normal;
|
||||
_vmMain.ProgressValue = 0;
|
||||
|
||||
reporter.Total = totalCount;
|
||||
reporter.Done = 0;
|
||||
reporter.ProgressText = $"[0/{totalCount}]";
|
||||
for (int i = 0; i < totalCount; i++)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
var skelPath = paths[i];
|
||||
reporter.ProgressText = $"[{i}/{totalCount}] {skelPath}";
|
||||
|
||||
try
|
||||
{
|
||||
var sp = new SpineObjectModel(skelPath);
|
||||
lock (_spineObjectModels.Lock) _spineObjectModels.Add(sp);
|
||||
success++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace(ex.ToString());
|
||||
_logger.Error("Failed to load: {0}, {1}", skelPath, ex.Message);
|
||||
error++;
|
||||
}
|
||||
|
||||
reporter.Done = i + 1;
|
||||
reporter.ProgressText = $"[{i + 1}/{totalCount}] {skelPath}";
|
||||
_vmMain.ProgressValue = (i + 1f) / totalCount;
|
||||
}
|
||||
_vmMain.ProgressState = TaskbarItemProgressState.None;
|
||||
|
||||
if (error > 0)
|
||||
_logger.Warn("Batch load {0} successfully, {1} failed", success, error);
|
||||
else
|
||||
_logger.Info("{0} skel loaded successfully", success);
|
||||
|
||||
_logger.LogCurrentProcessMemoryUsage();
|
||||
}
|
||||
}
|
||||
}
|
||||
830
SpineViewer/ViewModels/SpineObjectTabViewModel.cs
Normal file
830
SpineViewer/ViewModels/SpineObjectTabViewModel.cs
Normal file
@@ -0,0 +1,830 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Spine;
|
||||
using Spine.SpineWrappers;
|
||||
using SpineViewer.Models;
|
||||
using System.Collections;
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
|
||||
namespace SpineViewer.ViewModels
|
||||
{
|
||||
public class SpineObjectTabViewModel : ObservableObject
|
||||
{
|
||||
private SpineObjectModel[] _selectedObjects = [];
|
||||
private readonly ObservableCollection<SkinViewModel> _skins = [];
|
||||
private readonly ObservableCollection<SlotAttachmentViewModel> _slots = [];
|
||||
private readonly ObservableCollection<AnimationTrackViewModel> _animationTracks = [];
|
||||
|
||||
public ImmutableArray<ISkeleton.Physics> PhysicsOptions { get; } = Enum.GetValues<ISkeleton.Physics>().ToImmutableArray();
|
||||
|
||||
public SpineObjectModel[] SelectedObjects
|
||||
{
|
||||
get => _selectedObjects;
|
||||
set
|
||||
{
|
||||
if (ReferenceEquals(_selectedObjects, value)) return;
|
||||
|
||||
// 清空之前的所有内容
|
||||
foreach (var obj in _selectedObjects)
|
||||
{
|
||||
obj.PropertyChanged -= SingleModel_PropertyChanged;
|
||||
obj.AnimationChanged -= SingleModel_AnimationChanged;
|
||||
}
|
||||
_skins.Clear();
|
||||
_slots.Clear();
|
||||
_animationTracks.Clear();
|
||||
|
||||
// 生成新的内容
|
||||
_selectedObjects = value ?? [];
|
||||
if (_selectedObjects.Length > 0)
|
||||
{
|
||||
foreach (var obj in _selectedObjects)
|
||||
{
|
||||
obj.PropertyChanged += SingleModel_PropertyChanged;
|
||||
obj.AnimationChanged += SingleModel_AnimationChanged;
|
||||
}
|
||||
|
||||
IEnumerable<string> commonSkinNames = _selectedObjects[0].Skins;
|
||||
foreach (var obj in _selectedObjects.Skip(1)) commonSkinNames = commonSkinNames.Intersect(obj.Skins);
|
||||
foreach (var name in commonSkinNames) _skins.Add(new(name, _selectedObjects));
|
||||
|
||||
IEnumerable<string> commonSlotNames = _selectedObjects[0].SlotAttachments.Keys;
|
||||
foreach (var obj in _selectedObjects.Skip(1)) commonSlotNames = commonSlotNames.Intersect(obj.SlotAttachments.Keys);
|
||||
foreach (var name in commonSlotNames) _slots.Add(new(name, _selectedObjects));
|
||||
|
||||
IEnumerable<int> commonTrackIndices = _selectedObjects[0].GetTrackIndices();
|
||||
foreach (var obj in _selectedObjects.Skip(1)) commonTrackIndices = commonTrackIndices.Intersect(obj.GetTrackIndices());
|
||||
foreach (var idx in commonTrackIndices) _animationTracks.Add(new(idx, _selectedObjects));
|
||||
}
|
||||
|
||||
OnPropertyChanged();
|
||||
|
||||
Cmd_AppendTrack.NotifyCanExecuteChanged();
|
||||
|
||||
OnPropertyChanged(nameof(Version));
|
||||
OnPropertyChanged(nameof(AssetsDir));
|
||||
OnPropertyChanged(nameof(SkelPath));
|
||||
OnPropertyChanged(nameof(AtlasPath));
|
||||
OnPropertyChanged(nameof(Name));
|
||||
OnPropertyChanged(nameof(FileVersion));
|
||||
|
||||
OnPropertyChanged(nameof(IsShown));
|
||||
OnPropertyChanged(nameof(UsePma));
|
||||
OnPropertyChanged(nameof(Physics));
|
||||
|
||||
OnPropertyChanged(nameof(Scale));
|
||||
OnPropertyChanged(nameof(FlipX));
|
||||
OnPropertyChanged(nameof(FlipY));
|
||||
OnPropertyChanged(nameof(X));
|
||||
OnPropertyChanged(nameof(Y));
|
||||
|
||||
OnPropertyChanged(nameof(DebugTexture));
|
||||
OnPropertyChanged(nameof(DebugBounds));
|
||||
OnPropertyChanged(nameof(DebugBones));
|
||||
OnPropertyChanged(nameof(DebugRegions));
|
||||
OnPropertyChanged(nameof(DebugMeshHulls));
|
||||
OnPropertyChanged(nameof(DebugMeshes));
|
||||
OnPropertyChanged(nameof(DebugBoundingBoxes));
|
||||
OnPropertyChanged(nameof(DebugPaths));
|
||||
OnPropertyChanged(nameof(DebugPoints));
|
||||
OnPropertyChanged(nameof(DebugClippings));
|
||||
}
|
||||
}
|
||||
|
||||
public SpineVersion? Version
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].Version;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.Version != val)) return null;
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
public string? AssetsDir
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].AssetsDir;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.AssetsDir != val)) return null;
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
public string? SkelPath
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].SkelPath;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.SkelPath != val)) return null;
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
public string? AtlasPath
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].AtlasPath;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.AtlasPath != val)) return null;
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
public string? Name
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].Name;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.Name != val)) return null;
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
public string? FileVersion
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].FileVersion;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.FileVersion != val)) return null;
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
public bool? IsShown
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].IsShown;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.IsShown != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.IsShown = (bool)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool? UsePma
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].UsePma;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.UsePma != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.UsePma = (bool)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public ISkeleton.Physics? Physics
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].Physics;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.Physics != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.Physics = (ISkeleton.Physics)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public float? Scale
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].Scale;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.Scale != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.Scale = (float)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool? FlipX
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].FlipX;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.FlipX != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.FlipX = (bool)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool? FlipY
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].FlipY;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.FlipY != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.FlipY = (bool)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public float? X
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].X;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.X != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.X = (float)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public float? Y
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].Y;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.Y != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.Y = (float)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<SkinViewModel> Skins => _skins;
|
||||
|
||||
public RelayCommand<IList?> Cmd_EnableSkins { get; } = new(
|
||||
args => { if (args is null) return; foreach (var s in args.OfType<SkinViewModel>()) s.Status = true; },
|
||||
args => { return args is not null && args.OfType<SkinViewModel>().Any(); }
|
||||
);
|
||||
|
||||
public RelayCommand<IList?> Cmd_DisableSkins { get; } = new(
|
||||
args => { if (args is null) return; foreach (var s in args.OfType<SkinViewModel>()) s.Status = false; },
|
||||
args => { return args is not null && args.OfType<SkinViewModel>().Any(); }
|
||||
);
|
||||
|
||||
public ObservableCollection<SlotAttachmentViewModel> Slots => _slots;
|
||||
|
||||
public RelayCommand<IList?> Cmd_ClearSlotsAttachment { get; } = new(
|
||||
args => { if (args is null) return; foreach (var s in args.OfType<SlotAttachmentViewModel>()) s.AttachmentName = null; },
|
||||
args => { return args is not null && args.OfType<SlotAttachmentViewModel>().Any(); }
|
||||
);
|
||||
|
||||
public ObservableCollection<AnimationTrackViewModel> AnimationTracks => _animationTracks;
|
||||
|
||||
public RelayCommand Cmd_AppendTrack => _cmd_AppendTrack ??= new(
|
||||
() =>
|
||||
{
|
||||
if (_selectedObjects.Length != 1) return;
|
||||
var sp = _selectedObjects[0];
|
||||
if (sp.Animations.Length <= 0) return;
|
||||
sp.SetAnimation(sp.GetTrackIndices().LastOrDefault(-1) + 1, sp.Animations[0]);
|
||||
},
|
||||
() => { return _selectedObjects.Length == 1; }
|
||||
);
|
||||
private RelayCommand? _cmd_AppendTrack;
|
||||
|
||||
public RelayCommand<IList?> Cmd_InsertTrack => _cmd_InsertTrack ??= new(
|
||||
args =>
|
||||
{
|
||||
if (_selectedObjects.Length != 1) return;
|
||||
var sp = _selectedObjects[0];
|
||||
|
||||
if (sp.Animations.Length <= 0) return;
|
||||
if (args is null) return;
|
||||
if (args.Count != 1) return;
|
||||
if (args[0] is not AnimationTrackViewModel vm) return;
|
||||
var idx = vm.TrackIndex;
|
||||
|
||||
if (idx <= 0) return;
|
||||
if (sp.GetTrackIndices().Contains(idx - 1)) return;
|
||||
sp.SetAnimation(idx - 1, sp.Animations[0]);
|
||||
},
|
||||
args =>
|
||||
{
|
||||
if (_selectedObjects.Length != 1) return false;
|
||||
var sp = _selectedObjects[0];
|
||||
|
||||
if (sp.Animations.Length <= 0) return false;
|
||||
if (args is null) return false;
|
||||
if (args.Count != 1) return false;
|
||||
if (args[0] is not AnimationTrackViewModel vm) return false;
|
||||
var idx = vm.TrackIndex;
|
||||
|
||||
if (idx <= 0) return false;
|
||||
if (sp.GetTrackIndices().Contains(idx - 1)) return false;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
private RelayCommand<IList?>? _cmd_InsertTrack;
|
||||
|
||||
public bool? DebugTexture
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].DebugTexture;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.DebugTexture != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.DebugTexture = (bool)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool? DebugBounds
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].DebugBounds;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.DebugBounds != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.DebugBounds = (bool)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool? DebugBones
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].DebugBones;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.DebugBones != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.DebugBones = (bool)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool? DebugRegions
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].DebugRegions;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.DebugRegions != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.DebugRegions = (bool)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool? DebugMeshHulls
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].DebugMeshHulls;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.DebugMeshHulls != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.DebugMeshHulls = (bool)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool? DebugMeshes
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].DebugMeshes;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.DebugMeshes != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.DebugMeshes = (bool)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool? DebugBoundingBoxes
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].DebugBoundingBoxes;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.DebugBoundingBoxes != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.DebugBoundingBoxes = (bool)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool? DebugPaths
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].DebugPaths;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.DebugPaths != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.DebugPaths = (bool)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool? DebugPoints
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].DebugPoints;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.DebugPoints != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.DebugPoints = (bool)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool? DebugClippings
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return null;
|
||||
var val = _selectedObjects[0].DebugClippings;
|
||||
if (_selectedObjects.Skip(1).Any(it => it.DebugClippings != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_selectedObjects.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _selectedObjects) sp.DebugClippings = (bool)value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 监听单个模型属性发生变化, 则更新聚合属性值
|
||||
/// </summary>
|
||||
private void SingleModel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(SpineObjectModel.IsShown)) OnPropertyChanged(nameof(IsShown));
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.UsePma)) OnPropertyChanged(nameof(UsePma));
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.Physics)) OnPropertyChanged(nameof(Physics));
|
||||
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.Scale)) OnPropertyChanged(nameof(Scale));
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.FlipX)) OnPropertyChanged(nameof(FlipX));
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.FlipY)) OnPropertyChanged(nameof(FlipY));
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.X)) OnPropertyChanged(nameof(X));
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.Y)) OnPropertyChanged(nameof(Y));
|
||||
|
||||
// Skins 变化在 SkinViewModel 中监听
|
||||
// Slots 变化在 SlotAttachmentViewModel 中监听
|
||||
// AnimationTracks 变化在 AnimationTrackViewModel 中监听
|
||||
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.DebugTexture)) OnPropertyChanged(nameof(DebugTexture));
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.DebugBounds)) OnPropertyChanged(nameof(DebugBounds));
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.DebugBones)) OnPropertyChanged(nameof(DebugBones));
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.DebugRegions)) OnPropertyChanged(nameof(DebugRegions));
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.DebugMeshHulls)) OnPropertyChanged(nameof(DebugMeshHulls));
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.DebugMeshes)) OnPropertyChanged(nameof(DebugMeshes));
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.DebugBoundingBoxes)) OnPropertyChanged(nameof(DebugBoundingBoxes));
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.DebugPaths)) OnPropertyChanged(nameof(DebugPaths));
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.DebugPoints)) OnPropertyChanged(nameof(DebugPoints));
|
||||
else if (e.PropertyName == nameof(SpineObjectModel.DebugClippings)) OnPropertyChanged(nameof(DebugClippings));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 监听单个模型动画轨道发生变化, 则重建聚合后的动画列表
|
||||
/// </summary>
|
||||
/// <param name="sender"></param>
|
||||
/// <param name="e"></param>
|
||||
private void SingleModel_AnimationChanged(object? sender, AnimationChangedEventArgs e)
|
||||
{
|
||||
// XXX: 这里应该有更好的实现, 当 e.AnimationName == null 的时候代表删除轨道需要重新构建列表
|
||||
// 但是目前无法识别是否增加了轨道, 因此总是重建列表
|
||||
|
||||
// 由于某些原因, 直接使用 Clear 会和 UI 逻辑冲突产生报错, 因此需要放到 Dispatcher 里延迟执行
|
||||
App.Current.Dispatcher.BeginInvoke(
|
||||
() =>
|
||||
{
|
||||
_animationTracks.Clear();
|
||||
IEnumerable<int> commonTrackIndices = _selectedObjects[0].GetTrackIndices();
|
||||
foreach (var obj in _selectedObjects.Skip(1)) commonTrackIndices = commonTrackIndices.Intersect(obj.GetTrackIndices());
|
||||
foreach (var idx in commonTrackIndices) _animationTracks.Add(new(idx, _selectedObjects));
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
public class SkinViewModel : ObservableObject
|
||||
{
|
||||
private readonly SpineObjectModel[] _spines;
|
||||
private readonly string _name;
|
||||
|
||||
public SkinViewModel(string name, SpineObjectModel[] spines)
|
||||
{
|
||||
_spines = spines;
|
||||
_name = name;
|
||||
|
||||
// 使用弱引用, 则此 ViewModel 被释放时无需显式退订事件
|
||||
foreach (var sp in _spines)
|
||||
{
|
||||
WeakEventManager<SpineObjectModel, SkinStatusChangedEventArgs>.AddHandler(
|
||||
sp,
|
||||
nameof(sp.SkinStatusChanged),
|
||||
SingleModel_SkinStatusChanged
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public string Name => _name;
|
||||
|
||||
public bool? Status
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_spines.Length <= 0) return null;
|
||||
var val = _spines[0].GetSkinStatus(_name);
|
||||
if (_spines.Skip(1).Any(it => it.GetSkinStatus(_name) != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_spines.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
bool changed = false;
|
||||
foreach (var sp in _spines) if (sp.SetSkinStatus(_name, (bool)value)) changed = true;
|
||||
if (changed) OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void SingleModel_SkinStatusChanged(object? sender, SkinStatusChangedEventArgs e)
|
||||
{
|
||||
if (e.Name == _name) OnPropertyChanged(nameof(Status));
|
||||
}
|
||||
}
|
||||
|
||||
public class SlotAttachmentViewModel : ObservableObject
|
||||
{
|
||||
private readonly SpineObjectModel[] _spines;
|
||||
private readonly string[] _attachmentNames = [];
|
||||
private readonly string _slotName;
|
||||
|
||||
public SlotAttachmentViewModel(string slotName, SpineObjectModel[] spines)
|
||||
{
|
||||
_spines = spines;
|
||||
_slotName = slotName;
|
||||
|
||||
if (_spines.Length > 0)
|
||||
{
|
||||
IEnumerable<string> attachmentNames = _spines[0].SlotAttachments[_slotName];
|
||||
foreach (var sp in _spines.Skip(1))
|
||||
attachmentNames = attachmentNames.Union(sp.SlotAttachments[_slotName]);
|
||||
_attachmentNames = attachmentNames.ToArray();
|
||||
}
|
||||
|
||||
// 使用弱引用, 则此 ViewModel 被释放时无需显式退订事件
|
||||
foreach (var sp in _spines)
|
||||
{
|
||||
WeakEventManager<SpineObjectModel, SlotAttachmentChangedEventArgs>.AddHandler(
|
||||
sp,
|
||||
nameof(sp.SlotAttachmentChanged),
|
||||
SingleModel_SlotAttachmentChanged
|
||||
);
|
||||
WeakEventManager<SpineObjectModel, SkinStatusChangedEventArgs>.AddHandler(
|
||||
sp,
|
||||
nameof(sp.SkinStatusChanged),
|
||||
SingleModel_SkinStatusChanged
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public RelayCommand Cmd_ClearAttachment => _cmd_ClearAttachment ??= new(() => AttachmentName = null);
|
||||
private RelayCommand? _cmd_ClearAttachment;
|
||||
|
||||
public ReadOnlyCollection<string> AttachmentNames => _attachmentNames.AsReadOnly();
|
||||
|
||||
public string SlotName => _slotName;
|
||||
|
||||
public string? AttachmentName
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_spines.Length <= 0) return null;
|
||||
var val = _spines[0].GetAttachment(_slotName);
|
||||
if (_spines.Skip(1).Any(it => it.GetAttachment(_slotName) != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_spines.Length <= 0) return;
|
||||
bool changed = false;
|
||||
foreach (var sp in _spines) if (sp.SetAttachment(_slotName, value)) changed = true;
|
||||
if (changed) OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void SingleModel_SlotAttachmentChanged(object? sender, SlotAttachmentChangedEventArgs e)
|
||||
{
|
||||
if (e.SlotName == _slotName) OnPropertyChanged(nameof(AttachmentName));
|
||||
}
|
||||
|
||||
private void SingleModel_SkinStatusChanged(object? sender, SkinStatusChangedEventArgs e)
|
||||
{
|
||||
// 如果皮肤发生改变, 则直接触发附件属性变化事件
|
||||
OnPropertyChanged(nameof(AttachmentName));
|
||||
}
|
||||
}
|
||||
|
||||
public class AnimationTrackViewModel : ObservableObject
|
||||
{
|
||||
private readonly SpineObjectModel[] _spines;
|
||||
private readonly string[] _animationNames = [];
|
||||
private readonly int _trackIndex;
|
||||
|
||||
public AnimationTrackViewModel(int trackIndex, SpineObjectModel[] spines)
|
||||
{
|
||||
_spines = spines;
|
||||
_trackIndex = trackIndex;
|
||||
|
||||
if (_spines.Length > 0)
|
||||
{
|
||||
IEnumerable<string> animationNames = _spines[0].Animations;
|
||||
foreach (var sp in _spines.Skip(1))
|
||||
animationNames = animationNames.Union(sp.Animations);
|
||||
_animationNames = animationNames.ToArray();
|
||||
}
|
||||
|
||||
// 使用弱引用, 则此 ViewModel 被释放时无需显式退订事件
|
||||
foreach (var sp in _spines)
|
||||
{
|
||||
WeakEventManager<SpineObjectModel, AnimationChangedEventArgs>.AddHandler(
|
||||
sp,
|
||||
nameof(sp.AnimationChanged),
|
||||
SingleModel_AnimationChanged
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public RelayCommand Cmd_ClearTrack => _cmd_ClearTrack ??= new(() => { foreach (var sp in _spines) sp.ClearTrack(_trackIndex); });
|
||||
private RelayCommand? _cmd_ClearTrack;
|
||||
|
||||
public ReadOnlyCollection<string> AnimationNames => _animationNames.AsReadOnly();
|
||||
|
||||
public int TrackIndex => _trackIndex;
|
||||
|
||||
public string? AnimationName
|
||||
{
|
||||
get
|
||||
{
|
||||
/// XXX: 空轨道和多选不相同都会返回 null
|
||||
if (_spines.Length <= 0) return null;
|
||||
var val = _spines[0].GetAnimation(_trackIndex);
|
||||
if (_spines.Skip(1).Any(it => it.GetAnimation(_trackIndex) != val)) return null;
|
||||
return val;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_spines.Length <= 0) return;
|
||||
if (value is null) return;
|
||||
foreach (var sp in _spines) sp.SetAnimation(_trackIndex, value);
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(AnimationDuration));
|
||||
}
|
||||
}
|
||||
|
||||
public float? AnimationDuration
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_spines.Length <= 0) return null;
|
||||
var ani = _spines[0].GetAnimation(_trackIndex);
|
||||
if (ani is null) return null;
|
||||
var val = _spines[0].GetAnimationDuration(ani);
|
||||
foreach (var sp in _spines.Skip(1))
|
||||
{
|
||||
var a = sp.GetAnimation(_trackIndex);
|
||||
if (a is null) return null;
|
||||
if (sp.GetAnimationDuration(a) != val) return null;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
private void SingleModel_AnimationChanged(object? sender, AnimationChangedEventArgs e)
|
||||
{
|
||||
if (e.TrackIndex == _trackIndex) OnPropertyChanged(nameof(AnimationName));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user