Compare commits

...

60 Commits

Author SHA1 Message Date
ww-rm
9040e02025 Merge pull request #140 from ww-rm/dev/wpf
v0.16.10
2025-10-29 21:35:34 +08:00
ww-rm
b3ba073368 完善日志 2025-10-29 21:34:30 +08:00
ww-rm
332019a667 修复文件夹无法自动创建的bug 2025-10-29 21:00:38 +08:00
ww-rm
add9cf157d 修改压缩路径 2025-10-29 20:54:35 +08:00
ww-rm
8b0ea750d8 Merge pull request #139 from ww-rm/dev/wpf
v0.16.10
2025-10-29 20:45:24 +08:00
ww-rm
733739921d update changelog 2025-10-29 20:44:20 +08:00
ww-rm
e0f46f521a update to v0.16.10 2025-10-29 20:43:43 +08:00
ww-rm
aa4245ef2a add linux release 2025-10-29 20:42:31 +08:00
ww-rm
a262538eba 增加linux条件依赖 2025-10-29 19:48:23 +08:00
ww-rm
2e4a5a75c0 修复着色器语法兼容性错误 2025-10-29 19:33:45 +08:00
ww-rm
9331656431 修改项目配置 2025-10-28 22:03:39 +08:00
ww-rm
64bc12db06 Merge pull request #136 from ww-rm/dev/wpf
v0.16.9
2025-10-27 23:51:47 +08:00
ww-rm
7a29fee641 update readme 2025-10-27 23:50:39 +08:00
ww-rm
49f95ddbb7 add readme 2025-10-27 23:48:21 +08:00
ww-rm
317ee71882 update changelog 2025-10-27 23:45:16 +08:00
ww-rm
7780fbda28 update ignore 2025-10-27 23:43:57 +08:00
ww-rm
b54c6a1777 update to v0.16.9 2025-10-27 23:43:52 +08:00
ww-rm
617157044c 增加透明度参数 2025-10-27 23:33:25 +08:00
ww-rm
29d7e8d9d8 移除依赖库 2025-10-27 22:26:33 +08:00
ww-rm
701d1fcf90 增加日志 2025-10-27 07:35:05 +08:00
ww-rm
df36d46528 增加动态进度条 2025-10-27 00:00:44 +08:00
ww-rm
3459f3af03 修复进度回调done值错误 2025-10-26 23:59:57 +08:00
ww-rm
5498508700 移除不受支持的格式 2025-10-26 23:16:40 +08:00
ww-rm
a61bb43250 增加preview命令 2025-10-26 22:14:34 +08:00
ww-rm
aace461ae0 修改方法名 2025-10-26 22:05:48 +08:00
ww-rm
c02cec9a18 修改图像质量默认值为100 2025-10-26 22:01:40 +08:00
ww-rm
31daed9e81 移除不受支持的图像格式 2025-10-26 21:49:58 +08:00
ww-rm
997d55350d 修复可能的资源泄露 2025-10-26 21:28:11 +08:00
ww-rm
cc6d1b6c00 更新注释 2025-10-26 19:35:43 +08:00
ww-rm
e14c54c3a4 调整时间轴处理顺序 2025-10-26 17:35:51 +08:00
ww-rm
5eba515eac 增加 query 命令 2025-10-26 17:31:20 +08:00
ww-rm
f878530184 重构 2025-10-26 16:30:13 +08:00
ww-rm
81d9224658 增加参数验证 2025-10-26 16:22:49 +08:00
ww-rm
9d9edb8bc4 增加 export 命令 2025-10-26 16:16:43 +08:00
ww-rm
d3b5814c6f small change 2025-10-26 15:52:47 +08:00
ww-rm
aade44cffb 增加注释 2025-10-26 15:19:09 +08:00
ww-rm
c4956b9c16 重构 2025-10-26 13:26:47 +08:00
ww-rm
7ca431b214 增加System.CommandLine库 2025-10-25 17:19:27 +08:00
ww-rm
74538ddf74 apng和mov格式参数改为枚举量类型 2025-10-25 17:04:39 +08:00
ww-rm
779500ee8e 修改ApngPred属性名为PredMethod 2025-10-25 16:47:00 +08:00
ww-rm
ee7c9e9e54 Merge pull request #132 from jayng9663/dev/wpf
Add --warmup option to control physics warmup loops
2025-10-24 23:07:13 +08:00
ww-rm
d335645dc1 remove unnecessary frame loops 2025-10-19 20:46:42 +08:00
ww-rm
0893bd4b54 Merge pull request #133 from ww-rm/dev/wpf
v0.16.8
2025-10-19 20:19:48 +08:00
ww-rm
862926b43e update to v0.16.8 2025-10-19 20:18:11 +08:00
ww-rm
0324ba7971 update changelog 2025-10-19 20:18:02 +08:00
Jay
6a17ec0397 Add --warmup option to control physics warmup loops
Create a new --warmup argument to specify the number of warmup loops for physics before export. This allows users to control how many times the animation is pre-processed to stabilize physics.
2025-10-19 04:59:34 -07:00
ww-rm
53a7700798 增加单独的参数拷贝方式 2025-10-19 17:59:33 +08:00
ww-rm
30608e05bc 修改窗口默认大小 2025-10-19 15:16:05 +08:00
ww-rm
3dcd7b22ca 增加皮肤和插槽的全部启用禁用菜单项 2025-10-19 15:10:45 +08:00
ww-rm
dae5d0b7c7 增加侧边栏折叠功能 2025-10-19 01:01:34 +08:00
ww-rm
f5d3f93cde 增加侧边栏图标样式 2025-10-19 00:05:46 +08:00
ww-rm
dbd7c13c32 Merge branch 'dev/wpf' of github.com:ww-rm/SpineViewer into dev/wpf 2025-10-17 22:44:50 +08:00
ww-rm
b662d8f68a Merge pull request #131 from jhq223/feature/cli-enhancements
Feat(CLI): Add Single-Frame Export and Fix Related Bugs
2025-10-17 22:44:33 +08:00
ww-rm
02445d36e5 增加实时状态保存 2025-10-17 22:41:49 +08:00
ww-rm
b178e48e84 去除默认的最小化提示弹框 2025-10-16 23:54:07 +08:00
ww-rm
c90713ffe7 change tolower to tolowerinvariant 2025-10-16 22:33:36 +08:00
jhq223
dc472cf2a8 Fix: Resolve frame export logic and slot visibility issues
This commit addresses two critical bugs in the single-frame export functionality of the CLI tool.

1.  **Corrects Export Mode Detection for Ambiguous Formats (.webp):**
    - Previously, any output format also supported by the video exporter (like `.webp`) would incorrectly trigger video export mode, ignoring the `--time` argument intended for single-frame captures.
    - The logic is now updated to prioritize the presence of the `--time` argument. If this argument is provided, the tool is forced into single-frame export mode, correctly handling formats like static `.webp`.
    - This was implemented by changing the `time` variable to a nullable float (`float?`) to reliably detect if the argument was passed.

2.  **Fixes "Slot Not Found" Error for `--hide-slot`:**
    - The operation to hide slots was being performed *before* the animation was applied to the skeleton. This caused failures when trying to hide slots that are only activated or have attachments during a specific animation.
    - The slot visibility logic has been moved to execute *after* the animation state is set and the skeleton is updated to the target frame. This ensures that the skeleton is in its final pose, making all relevant slots available for modification.
2025-10-16 20:57:50 +08:00
jhq223
03c599264e feat(cli): Add single-frame image export
Extends the CLI to support exporting single frames as images (.png, .jpg, etc.) in addition to video.

The export logic now determines the output type based on the file extension of the `--output` path.

- Adds new arguments: `--time` to specify the frame and `--quality` for image compression.
- Uses `FrameExporter` for recognized image formats.
- Updates the help message with the new options.
2025-10-16 19:56:13 +08:00
ww-rm
8f7297bea5 Merge pull request #129 from jhq223/feature/cli-enhancements
feat: Add --skin and --hide-slot CLI arguments
2025-10-15 20:59:40 +08:00
jhq223
e4d655012b feat: Add --skin and --hide-slot CLI arguments 2025-10-15 16:01:18 +08:00
58 changed files with 2279 additions and 623 deletions

View File

@@ -11,6 +11,10 @@ jobs:
build-release:
if: ${{ github.event.pull_request.merged == true }}
runs-on: windows-latest
outputs:
version: ${{ steps.extract_version.outputs.version }}
upload_url: ${{ steps.create_release.outputs.upload_url }}
env:
PROJECT_NAME: SpineViewer
PROJ_CLI_NAME: SpineViewerCLI
@@ -27,21 +31,15 @@ jobs:
dotnet-version: "8.0.x"
- name: Extract version from csproj
id: extract_version
shell: pwsh
run: |
[xml]$proj = Get-Content "$env:PROJECT_NAME\$env:PROJECT_NAME.csproj"
$VERSION_NUM = $proj.Project.PropertyGroup.Version
$VERSION_TAG = "v$VERSION_NUM".Trim()
"VERSION=$VERSION_TAG" >> $env:GITHUB_ENV
- name: Check Version Tag
shell: pwsh
run: |
if (-not $env:VERSION) {
Write-Error "Version tag not found in csproj file."
exit 1
}
Write-Host "Version tag found: $env:VERSION"
echo "Version tag found: $VERSION_TAG"
echo "version=$VERSION_TAG" >> $env:GITHUB_OUTPUT
echo "VERSION=$VERSION_TAG" >> $env:GITHUB_ENV
- name: Tag merge commit
shell: pwsh
@@ -63,19 +61,11 @@ jobs:
dotnet publish "$env:PROJECT_NAME\$env:PROJECT_NAME.csproj" -c Release -r win-x64 --sc true -o "publish\$env:PROJECT_NAME-$env:VERSION-SelfContained"
dotnet publish "$env:PROJ_CLI_NAME\$env:PROJ_CLI_NAME.csproj" -c Release -r win-x64 --sc true -o "publish\$env:PROJECT_NAME-$env:VERSION-SelfContained"
- name: Create release directory
- name: Compress Windows builds
shell: pwsh
run: |
New-Item -ItemType Directory -Path release -Force | Out-Null
- name: Compress FrameworkDependent version
shell: pwsh
run: |
Compress-Archive -Path "publish\$env:PROJECT_NAME-$env:VERSION\*" -DestinationPath "release\$env:PROJECT_NAME-$env:VERSION.zip" -Force
- name: Compress SelfContained version
shell: pwsh
run: |
Compress-Archive -Path "publish\$env:PROJECT_NAME-$env:VERSION-SelfContained\*" -DestinationPath "release\$env:PROJECT_NAME-$env:VERSION-SelfContained.zip" -Force
- name: Create GitHub Release
@@ -89,7 +79,7 @@ jobs:
draft: false
prerelease: false
- name: Upload FrameworkDependent zip
- name: Upload Windows FrameworkDependent zip
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -99,7 +89,7 @@ jobs:
asset_name: ${{ env.PROJECT_NAME }}-${{ env.VERSION }}.zip
asset_content_type: application/zip
- name: Upload SelfContained zip
- name: Upload Windows SelfContained zip
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -108,3 +98,43 @@ jobs:
asset_path: release/${{ env.PROJECT_NAME }}-${{ env.VERSION }}-SelfContained.zip
asset_name: ${{ env.PROJECT_NAME }}-${{ env.VERSION }}-SelfContained.zip
asset_content_type: application/zip
build-release-linux:
needs: build-release
if: ${{ github.event.pull_request.merged == true }}
runs-on: ubuntu-latest
env:
PROJ_CLI_NAME: SpineViewerCLI
VERSION: ${{ needs.build-release.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-tags: true
- name: Setup .NET SDK
uses: actions/setup-dotnet@v3
with:
dotnet-version: "8.0.x"
- name: Publish Linux SelfContained version
run: |
dotnet publish "$PROJ_CLI_NAME/$PROJ_CLI_NAME.csproj" -c Release -r linux-x64 --sc true -o "publish/${PROJ_CLI_NAME}-${VERSION}-Linux-SelfContained"
- name: Compress Linux build
run: |
mkdir -p release
cd publish
zip -r "../release/${PROJ_CLI_NAME}-${VERSION}-Linux-SelfContained.zip" "${PROJ_CLI_NAME}-${VERSION}-Linux-SelfContained"
- name: Upload Linux zip to GitHub Release
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.build-release.outputs.upload_url }}
asset_path: release/${{ env.PROJ_CLI_NAME }}-${{ env.VERSION }}-Linux-SelfContained.zip
asset_name: ${{ env.PROJ_CLI_NAME }}-${{ env.VERSION }}-Linux-SelfContained.zip
asset_content_type: application/zip

2
.gitignore vendored
View File

@@ -396,3 +396,5 @@ FodyWeavers.xsd
# JetBrains Rider
*.sln.iml
launchSettings.json

View File

@@ -1,5 +1,22 @@
# CHANGELOG
## v0.16.10
- 增加 Linux 平台 CLI 工具构建
## v0.16.9
- 重构 CLI 工具
## v0.16.8
- 去除首次的最小化提示弹框
- 窗口布局改变后实时保存
- 增加侧边栏图标和折叠功能
- 增加皮肤和插槽参数面板的全部启用/禁用菜单项
- 修改窗口默认大小
- 支持复制并应用单独的模型皮肤或插槽参数
## v0.16.7
- 修复空帧导致的包围盒计算错误

View File

@@ -4,6 +4,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<TargetFramework>net8.0-windows</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>

View File

@@ -143,6 +143,7 @@ For detailed usage and documentation, see the [Wiki](https://github.com/ww-rm/Sp
- [HandyControl](https://github.com/HandyOrg/HandyControl)
- [NLog](https://github.com/NLog/NLog)
- [SkiaSharp](https://github.com/mono/SkiaSharp)
- [Spectre.Console](https://github.com/spectreconsole/spectre.console)
---

View File

@@ -142,6 +142,7 @@ https://github.com/user-attachments/assets/37b6b730-088a-4352-827a-c338127a16f0
- [HandyControl](https://github.com/HandyOrg/HandyControl)
- [NLog](https://github.com/NLog/NLog)
- [SkiaSharp](https://github.com/mono/SkiaSharp)
- [Spectre.Console](https://github.com/spectreconsole/spectre.console)
---

View File

@@ -4,6 +4,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<TargetFramework>net8.0-windows</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>

View File

@@ -1,18 +1,14 @@
using SFML.Graphics;
using SFML.System;
using SkiaSharp;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
namespace SpineViewer.Extensions
namespace Spine.Exporters
{
public static class SFMLExtension
public static class Extension
{
/// <summary>
/// 获取适合指定画布参数下能够覆盖包围盒的画布视区包围盒
@@ -53,31 +49,11 @@ namespace SpineViewer.Extensions
public static FloatRect GetBounds(this View self)
{
return new(
self.Center.X - self.Size.X / 2,
self.Center.Y - self.Size.Y / 2,
self.Size.X,
self.Center.X - self.Size.X / 2,
self.Center.Y - self.Size.Y / 2,
self.Size.X,
self.Size.Y
);
}
public static FloatRect ToFloatRect(this Rect self)
{
return new((float)self.X, (float)self.Y, (float)self.Width, (float)self.Height);
}
public static Vector2f ToVector2f(this Size self)
{
return new((float)self.Width, (float)self.Height);
}
public static Vector2u ToVector2u(this Size self)
{
return new((uint)self.Width, (uint)self.Height);
}
public static Vector2i ToVector2i(this Size self)
{
return new((int)self.Width, (int)self.Height);
}
}
}

View File

@@ -35,6 +35,33 @@ namespace Spine.Exporters
Mov,
}
/// <summary>
/// Apng 格式预测器算法
/// </summary>
public enum ApngPredMethod
{
None = 0,
Sub = 1,
Up = 2,
Avg = 3,
Paeth = 4,
Mixed = 5,
}
/// <summary>
/// Mov prores_ks 编码器 profile 参数
/// </summary>
public enum MovProfile
{
Auto = -1,
Proxy = 0,
Light = 1,
Standard = 2,
High = 3,
Yuv4444 = 4,
Yuv4444Extreme = 5,
}
/// <summary>
/// 视频格式
/// </summary>
@@ -60,10 +87,10 @@ namespace Spine.Exporters
private bool _lossless = false;
/// <summary>
/// [Apng] 预测器算法, 取值范围 0-5, 分别对应 none, sub, up, avg, paeth, mixed
/// [Apng] 预测器算法
/// </summary>
public int ApngPred { get => _apngPred; set => _apngPred = Math.Clamp(value, 0, 5); }
private int _apngPred = 5;
public ApngPredMethod PredMethod { get => _predMethod; set => _predMethod = value; }
private ApngPredMethod _predMethod = ApngPredMethod.Mixed;
/// <summary>
/// [Mp4/Webm/Mkv] CRF
@@ -72,10 +99,10 @@ namespace Spine.Exporters
private int _crf = 23;
/// <summary>
/// [Mov] prores_ks 编码器的配置等级, -1 是自动, 越高质量越好, 只有 4 及以上才有透明通道
/// [Mov] prores_ks 编码器的配置等级, 越高质量越好, 只有 <see cref="MovProfile.Yuv4444"> 及以上才有透明通道
/// </summary>
public int Profile { get => _profile; set => _profile = Math.Clamp(value, -1, 5); }
private int _profile = 5;
public MovProfile Profile { get => _profile; set => _profile = value; }
private MovProfile _profile = MovProfile.Yuv4444Extreme;
/// <summary>
/// 获取的一帧, 结果是预乘的
@@ -142,7 +169,7 @@ namespace Spine.Exporters
private void SetApngOptions(FFMpegArgumentOptions options)
{
var customArgs = $"-vf unpremultiply=inplace=1 -plays {(_loop ? 0 : 1)} -pred {_apngPred}";
var customArgs = $"-vf unpremultiply=inplace=1 -plays {(_loop ? 0 : 1)} -pred {(int)_predMethod}";
options.ForceFormat("apng").WithVideoCodec("apng").ForcePixelFormat("rgba")
.WithCustomArgument(customArgs);
}
@@ -179,7 +206,7 @@ namespace Spine.Exporters
var customArgs = "-vf unpremultiply=inplace=1";
options.ForceFormat("mov").WithVideoCodec("prores_ks").ForcePixelFormat("yuva444p10le")
.WithFastStart()
.WithCustomArgument($"-profile {_profile}")
.WithCustomArgument($"-profile {(int)_profile}")
.WithCustomArgument(customArgs);
}
}

View File

@@ -18,11 +18,27 @@ namespace Spine.Exporters
public FrameExporter(uint width = 100, uint height = 100) : base(width, height) { }
public FrameExporter(Vector2u resolution) : base(resolution) { }
public SKEncodedImageFormat Format { get => _format; set => _format = value; }
public SKEncodedImageFormat Format
{
get => _format;
set {
switch (value)
{
case SKEncodedImageFormat.Jpeg:
case SKEncodedImageFormat.Png:
case SKEncodedImageFormat.Webp:
_format = value;
break;
default:
_logger.Warn("Omit unsupported exporter format: {0}", value);
break;
}
}
}
protected SKEncodedImageFormat _format = SKEncodedImageFormat.Png;
public int Quality { get => _quality; set => _quality = Math.Clamp(value, 0, 100); }
protected int _quality = 80;
protected int _quality = 100;
public override void Export(string output, params SpineObject[] spines)
{
@@ -33,5 +49,15 @@ namespace Spine.Exporters
using var stream = File.OpenWrite(output);
data.SaveTo(stream);
}
/// <summary>
/// 获取帧图像, 结果是预乘的
/// </summary>
public SKImage ExportMemoryImage(params SpineObject[] spines)
{
using var frame = GetFrame(spines);
var info = new SKImageInfo(frame.Width, frame.Height, SKColorType.Rgba8888, SKAlphaType.Premul);
return SKImage.FromPixelCopy(info, frame.Image.Pixels);
}
}
}

View File

@@ -24,7 +24,7 @@ namespace Spine.Exporters
int frameCount = GetFrameCount();
int frameIdx = 0;
_progressReporter?.Invoke(frameCount, 0, $"[{frameIdx}/{frameCount}] {output}");
_progressReporter?.Invoke(frameCount, 0, $"[0/{frameCount}] {output}"); // 导出帧序列单独在此处调用进度报告
foreach (var frame in GetFrames(spines))
{
if (ct.IsCancellationRequested)
@@ -37,7 +37,7 @@ namespace Spine.Exporters
var savePath = Path.Combine(output, $"frame_{_fps}_{frameIdx:d6}.png");
var info = new SKImageInfo(frame.Width, frame.Height, SKColorType.Rgba8888, SKAlphaType.Premul);
_progressReporter?.Invoke(frameCount, frameIdx, $"[{frameIdx + 1}/{frameCount}] {savePath}");
_progressReporter?.Invoke(frameCount, frameIdx + 1, $"[{frameIdx + 1}/{frameCount}] {savePath}");
try
{
using var skImage = SKImage.FromPixelCopy(info, frame.Image.Pixels);

View File

@@ -92,7 +92,7 @@ namespace Spine.Exporters
}
/// <summary>
/// 生成帧序列
/// 生成帧序列, 用于导出帧序列
/// </summary>
protected IEnumerable<SFMLImageVideoFrame> GetFrames(SpineObject[] spines)
{
@@ -121,14 +121,14 @@ namespace Spine.Exporters
}
/// <summary>
/// 生成帧序列, 支持中途取消和进度输出
/// 生成帧序列, 支持中途取消和进度输出, 用于动图视频等单个文件输出
/// </summary>
protected IEnumerable<SFMLImageVideoFrame> GetFrames(SpineObject[] spines, string output, CancellationToken ct)
{
int frameCount = GetFrameCount();
int frameIdx = 0;
_progressReporter?.Invoke(frameCount, 0, $"[{frameIdx}/{frameCount}] {output}");
_progressReporter?.Invoke(frameCount, 0, $"[0/{frameCount}] {output}");
foreach (var frame in GetFrames(spines))
{
if (ct.IsCancellationRequested)
@@ -138,7 +138,7 @@ namespace Spine.Exporters
break;
}
_progressReporter?.Invoke(frameCount, frameIdx, $"[{frameIdx + 1}/{frameCount}] {output}");
_progressReporter?.Invoke(frameCount, frameIdx + 1, $"[{frameIdx + 1}/{frameCount}] {output}");
yield return frame;
frameIdx++;
}

View File

@@ -4,10 +4,11 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<TargetFramework>net8.0-windows</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<TargetFramework>net8.0</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>0.16.7</Version>
<Version>0.16.10</Version>
</PropertyGroup>
<PropertyGroup>
@@ -21,6 +22,10 @@
<PackageReference Include="SkiaSharp" Version="3.119.0" />
</ItemGroup>
<ItemGroup Condition="'$(RuntimeIdentifier)' == 'linux-x64' Or '$(RuntimeIdentifier)' == 'linux-arm64'">
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.119.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SpineRuntimes\SpineRuntime21\SpineRuntime21.csproj" />
<ProjectReference Include="..\SpineRuntimes\SpineRuntime34\SpineRuntime34.csproj" />

View File

@@ -14,7 +14,7 @@ namespace Spine.Utils
/// </summary>
private const string FRAGMENT_VertexAlpha =
"uniform sampler2D t;" +
"void main() { vec4 p = texture(t, gl_TexCoord[0].xy);" +
"void main() { vec4 p = texture2D(t, gl_TexCoord[0].xy);" +
"p.rgb *= p.a * gl_Color.a;" +
"gl_FragColor = gl_Color * p; }"
;
@@ -24,7 +24,7 @@ namespace Spine.Utils
/// </summary>
private const string FRAGMENT_VertexAlphaPma =
"uniform sampler2D t;" +
"void main() { vec4 p = texture(t, gl_TexCoord[0].xy);" +
"void main() { vec4 p = texture2D(t, gl_TexCoord[0].xy);" +
"p.rgb *= gl_Color.a;" +
"gl_FragColor = gl_Color * p; }"
;
@@ -34,8 +34,8 @@ namespace Spine.Utils
/// </summary>
private const string FRAGMENT_InvPma =
"uniform sampler2D t;" +
"void main() { vec4 p = texture(t, gl_TexCoord[0].xy);" +
"if (p.a > 0) p.rgb /= max(max(max(p.r, p.g), p.b), p.a);" +
"void main() { vec4 p = texture2D(t, gl_TexCoord[0].xy);" +
"if (p.a > 0.0) p.rgb /= max(max(max(p.r, p.g), p.b), p.a);" +
"gl_FragColor = p; }"
;

View File

@@ -4,7 +4,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<TargetFramework>net8.0-windows</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<TargetFramework>net8.0</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>2.1.25</Version>

View File

@@ -4,7 +4,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<TargetFramework>net8.0-windows</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<TargetFramework>net8.0</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>3.4.2</Version>

View File

@@ -4,7 +4,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<TargetFramework>net8.0-windows</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<TargetFramework>net8.0</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>3.5.51</Version>

View File

@@ -4,7 +4,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<TargetFramework>net8.0-windows</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<TargetFramework>net8.0</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>3.6.53</Version>

View File

@@ -4,7 +4,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<TargetFramework>net8.0-windows</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<TargetFramework>net8.0</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>3.7.94</Version>

View File

@@ -4,7 +4,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<TargetFramework>net8.0-windows</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<TargetFramework>net8.0</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>3.8.99</Version>

View File

@@ -4,7 +4,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<TargetFramework>net8.0-windows</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<TargetFramework>net8.0</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>4.0.64</Version>

View File

@@ -4,7 +4,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<TargetFramework>net8.0-windows</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<TargetFramework>net8.0</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>4.1.54</Version>

View File

@@ -4,7 +4,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<TargetFramework>net8.0-windows</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<TargetFramework>net8.0</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>4.2.74</Version>

View File

@@ -84,12 +84,14 @@ namespace SpineViewer
var fileTarget = new NLog.Targets.FileTarget("fileTarget")
{
Encoding = System.Text.Encoding.UTF8,
Layout = "${date:format=yyyy-MM-dd HH\\:mm\\:ss} - ${level:uppercase=true} - ${processid} - ${callsite-filename:includeSourcePath=false}:${callsite-linenumber} - ${message}",
AutoFlush = true,
CreateDirs = true,
FileName = "${basedir}/logs/app.log",
ArchiveFileName = "${basedir}/logs/app.{#}.log",
ArchiveNumbering = NLog.Targets.ArchiveNumberingMode.Rolling,
ArchiveAboveSize = 1048576,
MaxArchiveFiles = 5,
Layout = "${date:format=yyyy-MM-dd HH\\:mm\\:ss} - ${level:uppercase=true} - ${processid} - ${callsite-filename:includeSourcePath=false}:${callsite-linenumber} - ${message}",
ConcurrentWrites = true,
KeepFileOpen = false,
};
@@ -331,7 +333,7 @@ namespace SpineViewer
get => _language;
set
{
var uri = $"Resources/Strings/{value.ToString().ToLower()}.xaml";
var uri = $"Resources/Strings/{value.ToString().ToLowerInvariant()}.xaml";
try
{
Resources.MergedDictionaries.Add(new() { Source = new(uri, UriKind.Relative) });
@@ -351,7 +353,7 @@ namespace SpineViewer
get => _skin;
set
{
var uri = $"Resources/Skins/{value.ToString().ToLower()}.xaml";
var uri = $"Resources/Skins/{value.ToString().ToLowerInvariant()}.xaml";
try
{
Resources.MergedDictionaries.Add(new() { Source = new(uri, UriKind.Relative) });

View File

@@ -1,19 +1,41 @@
using SkiaSharp;
using SFML.Graphics;
using SFML.System;
using SkiaSharp;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Media;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Runtime.InteropServices;
namespace SpineViewer.Extensions
{
public static class WpfExtension
{
public static FloatRect ToFloatRect(this Rect self)
{
return new((float)self.X, (float)self.Y, (float)self.Width, (float)self.Height);
}
public static Vector2f ToVector2f(this Size self)
{
return new((float)self.Width, (float)self.Height);
}
public static Vector2u ToVector2u(this Size self)
{
return new((uint)self.Width, (uint)self.Height);
}
public static Vector2i ToVector2i(this Size self)
{
return new((int)self.Width, (int)self.Height);
}
/// <summary>
/// 从本地 WebP 文件读取,并保留透明度,返回一个可以直接用于 WPF Image.Source 的 BitmapSource。
/// </summary>
@@ -40,7 +62,7 @@ namespace SpineViewer.Extensions
//public static void SaveToFile(this BitmapSource bitmap, string path)
//{
// var ext = Path.GetExtension(path)?.ToLower();
// var ext = Path.GetExtension(path)?.ToLowerInvariant();
// BitmapEncoder encoder = ext switch
// {
// ".jpg" or ".jpeg" => new JpegBitmapEncoder(),

View File

@@ -102,7 +102,7 @@ namespace SpineViewer.Models
private bool _wallpaperView;
[ObservableProperty]
private bool? _closeToTray = null;
private bool _closeToTray;
[ObservableProperty]
private bool _autoRun;

View File

@@ -1,4 +1,5 @@
using Spine.Interfaces;
using SpineViewer.Utils;
using System;
using System.Collections.Generic;
using System.IO;
@@ -13,6 +14,15 @@ namespace SpineViewer.Models
{
public class SpineObjectConfigModel
{
public class TrackConfigModel
{
public string AnimationName { get; set; } = "";
public float TimeScale { get; set; } = 1f;
public float Alpha { get; set; } = 1f;
}
public bool UsePma { get; set; }
public string Physics { get; set; } = ISkeleton.Physics.Update.ToString();
@@ -57,14 +67,6 @@ namespace SpineViewer.Models
public bool DebugClippings { get; set; }
}
public class TrackConfigModel
{
public string AnimationName { get; set; } = "";
public float TimeScale { get; set; } = 1f;
public float Alpha { get; set; } = 1f;
public override string ToString() => JsonHelper.Serialize(this);
}
}

View File

@@ -91,6 +91,8 @@ namespace SpineViewer.Models
public event EventHandler<TrackPropertyChangedEventArgs>? TrackPropertyChanged;
#region
public SpineVersion Version => _spineObject.Version;
public string AssetsDir => _spineObject.AssetsDir;
@@ -407,6 +409,8 @@ namespace SpineViewer.Models
set { lock (_lock) SetProperty(_spineObject.DebugClippings, value, v => _spineObject.DebugClippings = v); }
}
#endregion
public void Update(float delta)
{
lock (_lock) _spineObject.Update(delta);
@@ -493,41 +497,57 @@ namespace SpineViewer.Models
return config;
}
}
set
set => ApplyObjectConfig(value);
}
public void ApplyObjectConfig(SpineObjectConfigModel m, SpineObjectConfigApplyFlag flag = SpineObjectConfigApplyFlag.All)
{
lock (_lock)
{
lock (_lock)
if (flag == SpineObjectConfigApplyFlag.All)
{
_spineObject.Skeleton.ScaleX = value.Scale;
_spineObject.Skeleton.ScaleY = value.Scale;
_spineObject.Skeleton.ScaleX = m.Scale;
_spineObject.Skeleton.ScaleY = m.Scale;
OnPropertyChanged(nameof(Scale));
SetProperty(_spineObject.Skeleton.ScaleX < 0, value.FlipX, v => _spineObject.Skeleton.ScaleX *= -1, nameof(FlipX));
SetProperty(_spineObject.Skeleton.ScaleY < 0, value.FlipY, v => _spineObject.Skeleton.ScaleY *= -1, nameof(FlipY));
SetProperty(_spineObject.Skeleton.X, value.X, v => _spineObject.Skeleton.X = v, nameof(X));
SetProperty(_spineObject.Skeleton.Y, value.Y, v => _spineObject.Skeleton.Y = v, nameof(Y));
SetProperty(_spineObject.UsePma, value.UsePma, v => _spineObject.UsePma = v, nameof(UsePma));
SetProperty(_spineObject.Physics, Enum.Parse<ISkeleton.Physics>(value.Physics ?? "Update", true), v => _spineObject.Physics = v, nameof(Physics));
SetProperty(_spineObject.AnimationState.TimeScale, value.TimeScale, v => _spineObject.AnimationState.TimeScale = v, nameof(TimeScale));
SetProperty(_spineObject.Skeleton.ScaleX < 0, m.FlipX, v => _spineObject.Skeleton.ScaleX *= -1, nameof(FlipX));
SetProperty(_spineObject.Skeleton.ScaleY < 0, m.FlipY, v => _spineObject.Skeleton.ScaleY *= -1, nameof(FlipY));
SetProperty(_spineObject.Skeleton.X, m.X, v => _spineObject.Skeleton.X = v, nameof(X));
SetProperty(_spineObject.Skeleton.Y, m.Y, v => _spineObject.Skeleton.Y = v, nameof(Y));
SetProperty(_spineObject.UsePma, m.UsePma, v => _spineObject.UsePma = v, nameof(UsePma));
SetProperty(_spineObject.Physics, Enum.Parse<ISkeleton.Physics>(m.Physics ?? "Update", true), v => _spineObject.Physics = v, nameof(Physics));
SetProperty(_spineObject.AnimationState.TimeScale, m.TimeScale, v => _spineObject.AnimationState.TimeScale = v, nameof(TimeScale));
}
foreach (var name in _spineObject.Data.Skins.Select(v => v.Name).Except(value.LoadedSkins))
if (flag == SpineObjectConfigApplyFlag.All || flag == SpineObjectConfigApplyFlag.Skin)
{
foreach (var name in _spineObject.Data.Skins.Select(v => v.Name).Except(m.LoadedSkins))
if (_spineObject.SetSkinStatus(name, false))
SkinStatusChanged?.Invoke(this, new(name, false));
foreach (var name in value.LoadedSkins)
foreach (var name in m.LoadedSkins)
if (_spineObject.SetSkinStatus(name, true))
SkinStatusChanged?.Invoke(this, new(name, true));
}
foreach (var (slotName, attachmentName) in value.SlotAttachment)
if (flag == SpineObjectConfigApplyFlag.SlotAttachement)
{
foreach (var (slotName, attachmentName) in m.SlotAttachment)
if (_spineObject.SetAttachment(slotName, attachmentName))
SlotAttachmentChanged?.Invoke(this, new(slotName, attachmentName));
}
foreach (var slotName in value.DisabledSlots)
if (flag == SpineObjectConfigApplyFlag.SlotVisibility)
{
foreach (var slotName in m.DisabledSlots)
if (_spineObject.SetSlotVisible(slotName, false))
SlotVisibleChanged?.Invoke(this, new(slotName, false));
}
if (flag == SpineObjectConfigApplyFlag.All)
{
// XXX: 处理空动画
_spineObject.AnimationState.ClearTracks();
int trackIndex = 0;
foreach (var trConfig in value.Animations)
foreach (var trConfig in m.Animations)
{
if (trConfig is not null && !string.IsNullOrEmpty(trConfig.AnimationName))
{
@@ -545,16 +565,16 @@ namespace SpineViewer.Models
_spineObject.Skeleton.SetSlotsToSetupPose();
}
SetProperty(_spineObject.DebugTexture, value.DebugTexture, v => _spineObject.DebugTexture = v, nameof(DebugTexture));
SetProperty(_spineObject.DebugBounds, value.DebugBounds, v => _spineObject.DebugBounds = v, nameof(DebugBounds));
SetProperty(_spineObject.DebugBones, value.DebugBones, v => _spineObject.DebugBones = v, nameof(DebugBones));
SetProperty(_spineObject.DebugRegions, value.DebugRegions, v => _spineObject.DebugRegions = v, nameof(DebugRegions));
SetProperty(_spineObject.DebugMeshHulls, value.DebugMeshHulls, v => _spineObject.DebugMeshHulls = v, nameof(DebugMeshHulls));
SetProperty(_spineObject.DebugMeshes, value.DebugMeshes, v => _spineObject.DebugMeshes = v, nameof(DebugMeshes));
SetProperty(_spineObject.DebugBoundingBoxes, value.DebugBoundingBoxes, v => _spineObject.DebugBoundingBoxes = v, nameof(DebugBoundingBoxes));
SetProperty(_spineObject.DebugPaths, value.DebugPaths, v => _spineObject.DebugPaths = v, nameof(DebugPaths));
SetProperty(_spineObject.DebugPoints, value.DebugPoints, v => _spineObject.DebugPoints = v, nameof(DebugPoints));
SetProperty(_spineObject.DebugClippings, value.DebugClippings, v => _spineObject.DebugClippings = v, nameof(DebugClippings));
SetProperty(_spineObject.DebugTexture, m.DebugTexture, v => _spineObject.DebugTexture = v, nameof(DebugTexture));
SetProperty(_spineObject.DebugBounds, m.DebugBounds, v => _spineObject.DebugBounds = v, nameof(DebugBounds));
SetProperty(_spineObject.DebugBones, m.DebugBones, v => _spineObject.DebugBones = v, nameof(DebugBones));
SetProperty(_spineObject.DebugRegions, m.DebugRegions, v => _spineObject.DebugRegions = v, nameof(DebugRegions));
SetProperty(_spineObject.DebugMeshHulls, m.DebugMeshHulls, v => _spineObject.DebugMeshHulls = v, nameof(DebugMeshHulls));
SetProperty(_spineObject.DebugMeshes, m.DebugMeshes, v => _spineObject.DebugMeshes = v, nameof(DebugMeshes));
SetProperty(_spineObject.DebugBoundingBoxes, m.DebugBoundingBoxes, v => _spineObject.DebugBoundingBoxes = v, nameof(DebugBoundingBoxes));
SetProperty(_spineObject.DebugPaths, m.DebugPaths, v => _spineObject.DebugPaths = v, nameof(DebugPaths));
SetProperty(_spineObject.DebugPoints, m.DebugPoints, v => _spineObject.DebugPoints = v, nameof(DebugPoints));
SetProperty(_spineObject.DebugClippings, m.DebugClippings, v => _spineObject.DebugClippings = v, nameof(DebugClippings));
}
}
}
@@ -613,6 +633,17 @@ namespace SpineViewer.Models
#endregion
}
/// <summary>
/// 可选的应用部分模型参数项
/// </summary>
public enum SpineObjectConfigApplyFlag
{
All,
Skin,
SlotAttachement,
SlotVisibility,
}
public class SkinStatusChangedEventArgs(string name, bool status) : EventArgs
{
public string Name { get; } = name;

View File

@@ -8,7 +8,7 @@ using System.Windows.Media;
namespace SpineViewer.Models
{
public class LastStateModel
public class UserStateModel
{
#region
@@ -18,6 +18,7 @@ namespace SpineViewer.Models
public double WindowHeight { get; set; }
public WindowState WindowState { get; set; }
public bool RootGridCol0Folded { get; set; }
public double RootGridCol0Width { get; set; }
public double RootGridCol2Width { get; set; }

View File

@@ -13,4 +13,9 @@
<Geometry x:Key="Geo_Stop" o:Freeze="True">M320 96c17.7 0 32 14.3 32 32l0 256c0 17.7-14.3 32-32 32L64 416c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l256 0zM64 64C28.7 64 0 92.7 0 128L0 384c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L64 64z</Geometry>
<Geometry x:Key="Geo_Folder" o:Freeze="True">M0 96C0 60.7 28.7 32 64 32l132.1 0c19.1 0 37.4 7.6 50.9 21.1L289.9 96 448 96c35.3 0 64 28.7 64 64l0 256c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM64 80c-8.8 0-16 7.2-16 16l0 320c0 8.8 7.2 16 16 16l384 0c8.8 0 16-7.2 16-16l0-256c0-8.8-7.2-16-16-16l-161.4 0c-10.6 0-20.8-4.2-28.3-11.7L213.1 87c-4.5-4.5-10.6-7-17-7L64 80z</Geometry>
<Geometry x:Key="Geo_ArrowRotateRight" o:Freeze="True">M472 224c13.3 0 24-10.7 24-24l0-144c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 80.1-20-23.5C387 63.4 325.1 32 256 32C132.3 32 32 132.3 32 256s100.3 224 224 224c50.4 0 97-16.7 134.4-44.8c10.6-8 12.7-23 4.8-33.6s-23-12.7-33.6-4.8C332.2 418.9 295.7 432 256 432c-97.2 0-176-78.8-176-176s78.8-176 176-176c54.3 0 102.9 24.6 135.2 63.4l.1 .2s0 0 0 0L418.9 176 328 176c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0z</Geometry>
<Geometry x:Key="Geo_Cubes" o:Freeze="True">M348 62.7C330.7 52.7 309.3 52.7 292 62.7L207.8 111.3C190.5 121.3 179.8 139.8 179.8 159.8L179.8 261.7L91.5 312.7C74.2 322.7 63.5 341.2 63.5 361.2L63.5 458.5C63.5 478.5 74.2 497 91.5 507L175.8 555.6C193.1 565.6 214.5 565.6 231.8 555.6L320.1 504.6L408.4 555.6C425.7 565.6 447.1 565.6 464.4 555.6L548.5 507C565.8 497 576.5 478.5 576.5 458.5L576.5 361.2C576.5 341.2 565.8 322.7 548.5 312.7L460.2 261.7L460.2 159.8C460.2 139.8 449.5 121.3 432.2 111.3L348 62.7zM135.5 342.7L203.8 303.3L272.1 342.7L203.8 382.1L135.5 342.7zM111.5 384.3L179.8 423.7L179.8 502.5L115.5 465.4C113 464 111.5 461.3 111.5 458.5L111.5 384.3zM227.8 502.5L227.8 423.7L296.1 384.3L296.1 463.1L227.8 502.5zM344 384.3L412.3 423.7L412.3 502.5L344 463.1L344 384.3zM460.3 502.5L460.3 423.7L528.6 384.3L528.6 458.5C528.6 461.4 527.1 464 524.6 465.4L460.3 502.5zM504.6 342.7L436.3 382.1L368 342.7L436.3 303.3L504.6 342.7zM344 301.2L344 222.4L412.3 183L412.3 261.8L344 301.2zM388.3 141.4L320 180.8L251.8 141.4L316 104.3C318.5 102.9 321.5 102.9 324 104.3L388.3 141.4zM227.8 182.9L296.1 222.3L296.1 301.1L227.8 261.7L227.8 182.9z</Geometry>
<Geometry x:Key="Geo_Image" o:Freeze="True">M160 144C151.2 144 144 151.2 144 160L144 480C144 488.8 151.2 496 160 496L480 496C488.8 496 496 488.8 496 480L496 160C496 151.2 488.8 144 480 144L160 144zM96 160C96 124.7 124.7 96 160 96L480 96C515.3 96 544 124.7 544 160L544 480C544 515.3 515.3 544 480 544L160 544C124.7 544 96 515.3 96 480L96 160zM224 192C241.7 192 256 206.3 256 224C256 241.7 241.7 256 224 256C206.3 256 192 241.7 192 224C192 206.3 206.3 192 224 192zM360 264C368.5 264 376.4 268.5 380.7 275.8L460.7 411.8C465.1 419.2 465.1 428.4 460.8 435.9C456.5 443.4 448.6 448 440 448L200 448C191.1 448 182.8 443 178.7 435.1C174.6 427.2 175.2 417.6 180.3 410.3L236.3 330.3C240.8 323.9 248.1 320.1 256 320.1C263.9 320.1 271.2 323.9 275.7 330.3L292.9 354.9L339.4 275.9C343.7 268.6 351.6 264.1 360.1 264.1z</Geometry>
<Geometry x:Key="Geo_File" o:Freeze="True">M304 112L192 112C183.2 112 176 119.2 176 128L176 512C176 520.8 183.2 528 192 528L448 528C456.8 528 464 520.8 464 512L464 272L376 272C336.2 272 304 239.8 304 200L304 112zM444.1 224L352 131.9L352 200C352 213.3 362.7 224 376 224L444.1 224zM128 128C128 92.7 156.7 64 192 64L325.5 64C342.5 64 358.8 70.7 370.8 82.7L493.3 205.3C505.3 217.3 512 233.6 512 250.6L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 128z</Geometry>
<Geometry x:Key="Geo_Gear" o:Freeze="True">M269.5 156.7L283.2 96L356.8 96L370.5 156.7C372.2 164.1 377.3 170.3 384.3 173.4C395.1 178.2 405.3 184.1 414.7 191C420.8 195.5 428.8 196.9 436.1 194.6L495.6 176.1L532.4 239.9L486.6 282.2C481 287.4 478.2 294.9 479 302.4C480.3 313.9 480.3 326.1 479 337.6C478.2 345.2 481 352.7 486.6 357.8L532.4 400.1L495.6 463.9L436.1 445.4C428.8 443.1 420.9 444.5 414.7 449C405.3 455.9 395.1 461.9 384.3 466.6C377.3 469.7 372.2 475.9 370.5 483.3L356.8 544L283.2 544L269.5 483.3C267.8 475.9 262.7 469.7 255.7 466.6C244.9 461.8 234.7 455.9 225.3 449C219.2 444.5 211.2 443.1 203.9 445.4L144.4 463.9L107.6 400.1L153.4 357.8C159 352.6 161.8 345.1 161 337.6C159.7 326.1 159.7 313.9 161 302.4C161.8 294.8 159 287.3 153.4 282.2L107.6 239.9L144.4 176.1L203.9 194.6C211.2 196.9 219.1 195.5 225.3 191C234.7 184.1 244.9 178.1 255.7 173.4C262.7 170.3 267.8 164.1 269.5 156.7zM276.8 48C258.1 48 241.9 61 237.8 79.2L225.2 134.8C218.9 138 212.9 141.5 207 145.3L152.6 128.4C134.7 122.8 115.4 130.4 106.1 146.6L62.9 221.4C53.6 237.6 56.7 258.1 70.4 270.8L112.3 309.5C112 316.4 112 323.5 112.3 330.5L70.4 369.2C56.7 381.9 53.5 402.4 62.9 418.6L106.1 493.4C115.4 509.6 134.8 517.1 152.6 511.6L207.1 494.7C213 498.5 219 502 225.3 505.2L237.9 560.8C242 579 258.2 592 276.9 592L363.3 592C382 592 398.2 579 402.3 560.8L414.9 505.2C421.2 502 427.2 498.5 433.1 494.7L487.6 511.6C505.5 517.2 524.8 509.6 534.1 493.4L577.3 418.6C586.6 402.4 583.5 381.9 569.8 369.2L527.9 330.5C528.2 323.6 528.2 316.5 527.9 309.5L569.8 270.8C583.5 258.1 586.6 237.6 577.3 221.4L534 146.6C524.6 130.4 505.3 122.9 487.5 128.4L433 145.3C427.1 141.5 421.1 138 414.8 134.8L402.3 79.2C398.1 61 381.9 48 363.2 48L276.8 48zM368 320C368 346.5 346.5 368 320 368C293.5 368 272 346.5 272 320C272 293.5 293.5 272 320 272C346.5 272 368 293.5 368 320zM320 224C267 224 224 267 224 320C224 373 267 416 320 416C373 416 416 373 416 320C416 267 373 224 320 224z</Geometry>
<Geometry x:Key="Geo_Sliders" o:Freeze="True">M88 136C74.7 136 64 146.7 64 160C64 173.3 74.7 184 88 184L179.7 184C189.9 216.5 220.2 240 256 240C291.8 240 322.1 216.5 332.3 184L552 184C565.3 184 576 173.3 576 160C576 146.7 565.3 136 552 136L332.3 136C322.1 103.5 291.8 80 256 80C220.2 80 189.9 103.5 179.7 136L88 136zM88 296C74.7 296 64 306.7 64 320C64 333.3 74.7 344 88 344L339.7 344C349.9 376.5 380.2 400 416 400C451.8 400 482.1 376.5 492.3 344L552 344C565.3 344 576 333.3 576 320C576 306.7 565.3 296 552 296L492.3 296C482.1 263.5 451.8 240 416 240C380.2 240 349.9 263.5 339.7 296L88 296zM88 456C74.7 456 64 466.7 64 480C64 493.3 74.7 504 88 504L147.7 504C157.9 536.5 188.2 560 224 560C259.8 560 290.1 536.5 300.3 504L552 504C565.3 504 576 493.3 576 480C576 466.7 565.3 456 552 456L300.3 456C290.1 423.5 259.8 400 224 400C188.2 400 157.9 423.5 147.7 456L88 456zM224 512C206.3 512 192 497.7 192 480C192 462.3 206.3 448 224 448C241.7 448 256 462.3 256 480C256 497.7 241.7 512 224 512zM416 352C398.3 352 384 337.7 384 320C384 302.3 398.3 288 416 288C433.7 288 448 302.3 448 320C448 337.7 433.7 352 416 352zM224 160C224 142.3 238.3 128 256 128C273.7 128 288 142.3 288 160C288 177.7 273.7 192 256 192C238.3 192 224 177.7 224 160z</Geometry>
</ResourceDictionary>

View File

@@ -45,7 +45,11 @@
<s:String x:Key="Str_Reload">Reload</s:String>
<s:String x:Key="Str_MoveUpSpineObject">Move Up</s:String>
<s:String x:Key="Str_MoveDownSpineObject">Move Down</s:String>
<s:String x:Key="Str_SpineObjectConfig">Model Config</s:String>
<s:String x:Key="Str_CopySpineObjectConfig">Copy Config</s:String>
<s:String x:Key="Str_CopySpineObjectSkinConfig">Copy Parameters (Skin Only)</s:String>
<s:String x:Key="Str_CopySpineObjectSlotAttachmentConfig">Copy Parameters (Slot Attachments Only)</s:String>
<s:String x:Key="Str_CopySpineObjectSlotVisibilityConfig">Copy Parameters (Slot Visibility Only)</s:String>
<s:String x:Key="Str_ApplySpineObjectConfig">Apply Config</s:String>
<s:String x:Key="Str_SaveSpineObjectConfigToFile">Save Config to File...</s:String>
<s:String x:Key="Str_ApplySpineObjectConfigFromFile">Apply Config from File...</s:String>
@@ -78,13 +82,13 @@
<s:String x:Key="Str_Y">Y</s:String>
<s:String x:Key="Str_Skin">Skin</s:String>
<s:String x:Key="Str_EnableSkins">Enable Skins</s:String>
<s:String x:Key="Str_DisableSkins">Disable Skins</s:String>
<s:String x:Key="Str_Enable">Enable</s:String>
<s:String x:Key="Str_Disable">Disable</s:String>
<s:String x:Key="Str_EnableAll">Enable All</s:String>
<s:String x:Key="Str_DisableAll">Disable All</s:String>
<s:String x:Key="Str_Slot">Slot</s:String>
<s:String x:Key="Str_ClearSlotsAttachment">Clear Slots Attachment</s:String>
<s:String x:Key="Str_EnableSlots">Enable Slots</s:String>
<s:String x:Key="Str_DisableSlots">Disable Slots</s:String>
<s:String x:Key="Str_Animation">Animation</s:String>
<s:String x:Key="Str_AppendTrack">Add</s:String>
@@ -202,12 +206,12 @@
<s:String x:Key="Str_QualityParameterTooltip" xml:space="preserve">[Webp]&#x0A;Quality parameter, range 0-100, higher value means better quality</s:String>
<s:String x:Key="Str_LosslessParam">Lossless Compression</s:String>
<s:String x:Key="Str_LosslessParamTooltip" xml:space="preserve">[Webp]&#x0A;Lossless compression, quality parameter will be ignored</s:String>
<s:String x:Key="Str_ApngPred">Predictor Method</s:String>
<s:String x:Key="Str_ApngPredTooltip" xml:space="preserve">[Apng]&#x0A;Pred parameter, value range 0-5, corresponding to different encoding strategies: none, sub, up, avg, paeth, and mixed.&#x0A;It affects encoding time and file size.</s:String>
<s:String x:Key="Str_PredMethod">Predictor Method</s:String>
<s:String x:Key="Str_PredMethodTooltip" xml:space="preserve">[Apng]&#x0A;Pred parameter, value range 0-5, corresponding to different encoding strategies: none, sub, up, avg, paeth, and mixed.&#x0A;It affects encoding time and file size.</s:String>
<s:String x:Key="Str_CrfParameter">CRF Parameter</s:String>
<s:String x:Key="Str_CrfParameterTooltip" xml:space="preserve">[Mp4/Webm/Mkv]&#x0A;CRF parameter, range 0-63, lower value means higher quality</s:String>
<s:String x:Key="Str_ProfileParameter">Profile Parameter</s:String>
<s:String x:Key="Str_ProfileParameterTooltip" xml:space="preserve">[Mov]&#x0A;Profile parameter, integer between -1 and 5,&#x0A;-1 means automatic, higher values indicate higher quality,&#x0A;Alpha channel encoding is only available when value is 4 or higher</s:String>
<s:String x:Key="Str_ProfileParameterTooltip" xml:space="preserve">[Mov]&#x0A;Profile parameter, an integer between -1 and 5,&#x0A;corresponding to: auto, proxy, lt, standard, hq, 4444, and 4444xq.&#x0A;Alpha channel encoding is available only when the value is 4 or higher.</s:String>
<s:String x:Key="Str_FFmpegFormat">Export Format</s:String>
<s:String x:Key="Str_FFmpegFormatTooltip">FFmpeg export format (equivalent to "-f"), e.g. "mp4", "webm"</s:String>

View File

@@ -45,7 +45,11 @@
<s:String x:Key="Str_Reload">再読み込み</s:String>
<s:String x:Key="Str_MoveUpSpineObject">上へ移動</s:String>
<s:String x:Key="Str_MoveDownSpineObject">下へ移動</s:String>
<s:String x:Key="Str_SpineObjectConfig">モデル設定</s:String>
<s:String x:Key="Str_CopySpineObjectConfig">パラメーターをコピー</s:String>
<s:String x:Key="Str_CopySpineObjectSkinConfig">パラメーターをコピー(スキンのみ)</s:String>
<s:String x:Key="Str_CopySpineObjectSlotAttachmentConfig">パラメーターをコピー(スロットのアタッチメントのみ)</s:String>
<s:String x:Key="Str_CopySpineObjectSlotVisibilityConfig">パラメーターをコピー(スロットの表示状態のみ)</s:String>
<s:String x:Key="Str_ApplySpineObjectConfig">パラメーターを適用</s:String>
<s:String x:Key="Str_SaveSpineObjectConfigToFile">パラメータファイルを保存...</s:String>
<s:String x:Key="Str_ApplySpineObjectConfigFromFile">パラメータファイルを適用...</s:String>
@@ -78,13 +82,13 @@
<s:String x:Key="Str_Y">Y座標</s:String>
<s:String x:Key="Str_Skin">スキン</s:String>
<s:String x:Key="Str_EnableSkins">有効</s:String>
<s:String x:Key="Str_DisableSkins">無効</s:String>
<s:String x:Key="Str_Enable">有効にする</s:String>
<s:String x:Key="Str_Disable">無効にする</s:String>
<s:String x:Key="Str_EnableAll">すべて有効にする</s:String>
<s:String x:Key="Str_DisableAll">すべて無効にする</s:String>
<s:String x:Key="Str_Slot">スロット</s:String>
<s:String x:Key="Str_ClearSlotsAttachment">アタッチメントをクリア</s:String>
<s:String x:Key="Str_EnableSlots">有効</s:String>
<s:String x:Key="Str_DisableSlots">無効</s:String>
<s:String x:Key="Str_Animation">アニメーション</s:String>
<s:String x:Key="Str_AppendTrack">追加</s:String>
@@ -202,12 +206,12 @@
<s:String x:Key="Str_QualityParameterTooltip" xml:space="preserve">[Webp]&#x0A;品質パラメータ、範囲は0-100。値が高いほど品質が良い</s:String>
<s:String x:Key="Str_LosslessParam">無損失圧縮</s:String>
<s:String x:Key="Str_LosslessParamTooltip" xml:space="preserve">[Webp]&#x0A;無損失圧縮、品質パラメータは無視されます</s:String>
<s:String x:Key="Str_ApngPred">予測器方式</s:String>
<s:String x:Key="Str_ApngPredTooltip" xml:space="preserve">[Apng]&#x0A;Pred パラメータ。値の範囲は 05 で、それぞれ none、sub、up、avg、paeth、mixed の異なるエンコード方式に対応します。&#x0A;エンコード時間とファイルサイズに影響します。</s:String>
<s:String x:Key="Str_PredMethod">予測器方式</s:String>
<s:String x:Key="Str_PredMethodTooltip" xml:space="preserve">[Apng]&#x0A;Pred パラメータ。値の範囲は 05 で、それぞれ none、sub、up、avg、paeth、mixed の異なるエンコード方式に対応します。&#x0A;エンコード時間とファイルサイズに影響します。</s:String>
<s:String x:Key="Str_CrfParameter">CRF パラメータ</s:String>
<s:String x:Key="Str_CrfParameterTooltip" xml:space="preserve">[Mp4/Webm/Mkv]&#x0A;CRF パラメータ、範囲0-63。値が小さいほど品質が高い</s:String>
<s:String x:Key="Str_ProfileParameter">プロファイルパラメータ</s:String>
<s:String x:Key="Str_ProfileParameterTooltip" xml:space="preserve">[Mov]&#x0A;プロファイルパラメータ、-1から5の整数、&#x0A;-1は自動、値が大きいほど品質が高い、&#x0A;値が4以上の場合のみアルファチャンネルエンコード可能</s:String>
<s:String x:Key="Str_ProfileParameterTooltip" xml:space="preserve">[Mov]&#x0A;Profile パラメータ。値は -1 5 の整数、&#x0A;それぞれ auto、proxy、lt、standard、hq、4444、4444xq に対応します。&#x0A;値が 4 以上の場合のみアルファチャンネルエンコード可能です。</s:String>
<s:String x:Key="Str_FFmpegFormat">エクスポートフォーマット</s:String>
<s:String x:Key="Str_FFmpegFormatTooltip">FFmpegエクスポートフォーマット。パラメーター“-f”に相当します。例: “mp4”、“webm”</s:String>

View File

@@ -45,7 +45,11 @@
<s:String x:Key="Str_Reload">重新加载</s:String>
<s:String x:Key="Str_MoveUpSpineObject">上移</s:String>
<s:String x:Key="Str_MoveDownSpineObject">下移</s:String>
<s:String x:Key="Str_SpineObjectConfig">模型参数</s:String>
<s:String x:Key="Str_CopySpineObjectConfig">复制参数</s:String>
<s:String x:Key="Str_CopySpineObjectSkinConfig">复制参数(仅皮肤)</s:String>
<s:String x:Key="Str_CopySpineObjectSlotAttachmentConfig">复制参数(仅插槽附件)</s:String>
<s:String x:Key="Str_CopySpineObjectSlotVisibilityConfig">复制参数(仅插槽可见性)</s:String>
<s:String x:Key="Str_ApplySpineObjectConfig">应用参数</s:String>
<s:String x:Key="Str_SaveSpineObjectConfigToFile">保存参数文件...</s:String>
<s:String x:Key="Str_ApplySpineObjectConfigFromFile">应用参数文件...</s:String>
@@ -78,13 +82,13 @@
<s:String x:Key="Str_Y">纵坐标</s:String>
<s:String x:Key="Str_Skin">皮肤</s:String>
<s:String x:Key="Str_EnableSkins">启用</s:String>
<s:String x:Key="Str_DisableSkins">禁用</s:String>
<s:String x:Key="Str_Enable">启用</s:String>
<s:String x:Key="Str_Disable">禁用</s:String>
<s:String x:Key="Str_EnableAll">全部启用</s:String>
<s:String x:Key="Str_DisableAll">全部禁用</s:String>
<s:String x:Key="Str_Slot">插槽</s:String>
<s:String x:Key="Str_ClearSlotsAttachment">清除附件</s:String>
<s:String x:Key="Str_EnableSlots">启用</s:String>
<s:String x:Key="Str_DisableSlots">禁用</s:String>
<s:String x:Key="Str_Animation">动画</s:String>
<s:String x:Key="Str_AppendTrack">添加</s:String>
@@ -202,12 +206,12 @@
<s:String x:Key="Str_QualityParameterTooltip" xml:space="preserve">[Webp]&#x0A;质量参数,取值范围 0-100越高质量越好</s:String>
<s:String x:Key="Str_LosslessParam">无损压缩</s:String>
<s:String x:Key="Str_LosslessParamTooltip" xml:space="preserve">[Webp]&#x0A;无损压缩,会忽略质量参数</s:String>
<s:String x:Key="Str_ApngPred">预测器方法</s:String>
<s:String x:Key="Str_ApngPredTooltip" xml:space="preserve">[Apng]&#x0A;Pred 参数,取值范围 0-5分别对应 none、sub、up、avg、paeth、mixed 几种不同的编码策略,&#x0A;影响编码时间和文件大小</s:String>
<s:String x:Key="Str_PredMethod">预测器方法</s:String>
<s:String x:Key="Str_PredMethodTooltip" xml:space="preserve">[Apng]&#x0A;Pred 参数,取值范围 0-5分别对应 none、sub、up、avg、paeth、mixed 几种不同的编码策略,&#x0A;影响编码时间和文件大小</s:String>
<s:String x:Key="Str_CrfParameter">CRF 参数</s:String>
<s:String x:Key="Str_CrfParameterTooltip" xml:space="preserve">[Mp4/Webm/Mkv]&#x0A;CRF 参数,取值范围 0-63越小质量越高</s:String>
<s:String x:Key="Str_ProfileParameter">Profile 参数</s:String>
<s:String x:Key="Str_ProfileParameterTooltip" xml:space="preserve">[Mov]&#x0A;Profile 参数,取值集合为 -1 到 5 之间的整数,&#x0A;-1 表示自动0-5 取值越高质量越高&#x0A;仅在取值大于等于 4 时可以编码透明度通道</s:String>
<s:String x:Key="Str_ProfileParameterTooltip" xml:space="preserve">[Mov]&#x0A;Profile 参数,取值范围为 -1 到 5 之间的整数,&#x0A;分别对应 auto、proxy、lt、standard、hq、4444、4444xq 几种配置&#x0A;仅在取值大于等于 4 时可以编码透明度通道</s:String>
<s:String x:Key="Str_FFmpegFormat">导出格式</s:String>
<s:String x:Key="Str_FFmpegFormatTooltip">FFmpeg 导出格式,等价于参数 “-f”例如 “mp4”、“webm”</s:String>

View File

@@ -34,7 +34,7 @@
<Style x:Key="MyListBoxBaseStyle" TargetType="{x:Type ListBox}" BasedOn="{StaticResource ListBoxBaseStyle}">
<Setter Property="SelectionMode" Value="Extended"/>
<Setter Property="VirtualizingPanel.IsVirtualizing" Value="False"/>
<!--<Setter Property="VirtualizingPanel.IsVirtualizing" Value="False"/>-->
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Visible"/>
<Setter Property="ItemContainerStyle">
<Setter.Value>
@@ -49,7 +49,7 @@
<Style x:Key="MyListViewBaseStyle" TargetType="{x:Type ListView}" BasedOn="{StaticResource ListViewBaseStyle}">
<Setter Property="SelectionMode" Value="Extended"/>
<Setter Property="VirtualizingPanel.IsVirtualizing" Value="False"/>
<!--<Setter Property="VirtualizingPanel.IsVirtualizing" Value="False"/>-->
<Setter Property="Background" Value="Transparent"/>
<Setter Property="ItemContainerStyle" Value="{StaticResource ListViewItemBaseStyle.Small}"/>
</Style>
@@ -76,6 +76,29 @@
</Style.Triggers>
</Style>
<Style x:Key="MyTabItemHeaderPathStyle" TargetType="Path">
<Setter Property="Stretch" Value="Uniform"/>
<Setter Property="Stroke" Value="{DynamicResource PrimaryTextBrush}"/>
<Setter Property="StrokeThickness" Value="20"/>
<Style.Triggers>
<DataTrigger Binding="{Binding RelativeSource={RelativeSource AncestorType=TabItem}, Path=IsSelected}" Value="True">
<Setter Property="Stroke" Value="{DynamicResource PrimaryBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="MyTabItemHeaderContainerStyle" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource RegionBrush}"/>
<Setter Property="Margin" Value="-10 -5 -8 -5"/>
<Setter Property="Padding" Value="10 5"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Background" Value="{DynamicResource SecondaryRegionBrush}"/>
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="{x:Type GridSplitter}" BasedOn="{StaticResource MyGridSplitterBaseStyle}"/>
<Style TargetType="{x:Type ToggleButton}" BasedOn="{StaticResource MyToggleButtonBaseStyle}"/>
<Style TargetType="{x:Type ListBox}" BasedOn="{StaticResource MyListBoxBaseStyle}"/>

View File

@@ -4,10 +4,11 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<TargetFramework>net8.0-windows</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>0.16.7</Version>
<Version>0.16.10</Version>
<OutputType>WinExe</OutputType>
<UseWPF>true</UseWPF>
</PropertyGroup>

View File

@@ -32,6 +32,7 @@ namespace SpineViewer.Utils
private static readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = true,
IndentSize = 4,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip
@@ -47,7 +48,6 @@ namespace SpineViewer.Utils
if (!quietForNotExist)
{
_logger.Error("Json file {0} not found", path);
MessagePopupService.Error($"Json file {path} not found");
}
}
else
@@ -62,13 +62,11 @@ namespace SpineViewer.Utils
return true;
}
_logger.Error("Null data in file {0}", path);
MessagePopupService.Error($"Null data in file {path}");
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to read json file {0}, {1}", path, ex.Message);
MessagePopupService.Error($"Failed to read json file {path}, {ex.ToString()}");
}
}
obj = default;
@@ -90,11 +88,24 @@ namespace SpineViewer.Utils
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to save json file {0}, {1}", path, ex.Message);
MessagePopupService.Error($"Failed to save json file {path}, {ex.ToString()}");
return false;
}
return true;
}
public static string Serialize<T>(T obj)
{
try
{
return JsonSerializer.Serialize(obj, _jsonOptions);
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to serialize json object {0}", ex.Message);
return string.Empty;
}
}
}
public class ColorJsonConverter : JsonConverter<Color>

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
namespace SpineViewer.Utils
{
public static class PropertyWatcher
{
public static IDisposable Watch(DependencyObject target, DependencyProperty property, Action callback)
{
var dpd = DependencyPropertyDescriptor.FromProperty(property, target.GetType());
if (dpd == null) return null;
EventHandler handler = (s, e) => callback();
dpd.AddValueChanged(target, handler);
return new Unsubscriber(() => dpd.RemoveValueChanged(target, handler));
}
private class Unsubscriber : IDisposable
{
private readonly Action _dispose;
public Unsubscriber(Action dispose) => _dispose = dispose;
public void Dispose() => _dispose();
}
}
}

View File

@@ -36,7 +36,7 @@ namespace SpineViewer.ViewModels.Exporters
public string? CustomArgs { get => _customArgs; set => SetProperty(ref _customArgs, value); }
protected string? _customArgs;
private string FormatSuffix => $".{_format.ToString().ToLower()}";
private string FormatSuffix => $".{_format.ToString().ToLowerInvariant()}";
public override string? Validate()
{

View File

@@ -19,6 +19,8 @@ namespace SpineViewer.ViewModels.Exporters
public class FFmpegVideoExporterViewModel(MainWindowViewModel vmMain) : VideoExporterViewModel(vmMain)
{
public static ImmutableArray<FFmpegVideoExporter.VideoFormat> VideoFormatOptions { get; } = Enum.GetValues<FFmpegVideoExporter.VideoFormat>().ToImmutableArray();
public static ImmutableArray<FFmpegVideoExporter.ApngPredMethod> ApngPredMethodOptions { get; } = Enum.GetValues<FFmpegVideoExporter.ApngPredMethod>().ToImmutableArray();
public static ImmutableArray<FFmpegVideoExporter.MovProfile> MovProfileOptions { get; } = Enum.GetValues<FFmpegVideoExporter.MovProfile>().ToImmutableArray();
public FFmpegVideoExporter.VideoFormat Format
{
@@ -57,8 +59,8 @@ namespace SpineViewer.ViewModels.Exporters
public bool EnableParamLossless =>
_format == FFmpegVideoExporter.VideoFormat.Webp;
public int ApngPred { get => _apngPred; set => SetProperty(ref _apngPred, Math.Clamp(value, 0, 5)); }
protected int _apngPred = 5;
public FFmpegVideoExporter.ApngPredMethod PredMethod { get => _predMethod; set => SetProperty(ref _predMethod, value); }
protected FFmpegVideoExporter.ApngPredMethod _predMethod = FFmpegVideoExporter.ApngPredMethod.Mixed;
public bool EnableParamApngPred =>
_format == FFmpegVideoExporter.VideoFormat.Apng;
@@ -71,13 +73,13 @@ namespace SpineViewer.ViewModels.Exporters
_format == FFmpegVideoExporter.VideoFormat.Webm ||
_format == FFmpegVideoExporter.VideoFormat.Mkv;
public int Profile { get => _profile; set => SetProperty(ref _profile, Math.Clamp(value, -1, 5)); }
protected int _profile = 5;
public FFmpegVideoExporter.MovProfile Profile { get => _profile; set => SetProperty(ref _profile, value); }
protected FFmpegVideoExporter.MovProfile _profile = FFmpegVideoExporter.MovProfile.Yuv4444Extreme;
public bool EnableParamProfile =>
_format == FFmpegVideoExporter.VideoFormat.Mov;
private string FormatSuffix => $".{_format.ToString().ToLower()}";
private string FormatSuffix => $".{_format.ToString().ToLowerInvariant()}";
protected override void Export(SpineObjectModel[] models)
{
@@ -102,7 +104,7 @@ namespace SpineViewer.ViewModels.Exporters
Loop = _loop,
Quality = _quality,
Lossless = _lossless,
ApngPred = _apngPred,
PredMethod = _predMethod,
Crf = _crf,
Profile = _profile,
};

View File

@@ -20,13 +20,17 @@ namespace SpineViewer.ViewModels.Exporters
{
public class FrameExporterViewModel(MainWindowViewModel vmMain) : BaseExporterViewModel(vmMain)
{
public static ImmutableArray<SKEncodedImageFormat> FrameFormatOptions { get; } = Enum.GetValues<SKEncodedImageFormat>().ToImmutableArray();
public static ImmutableArray<SKEncodedImageFormat> FrameFormatOptions { get; } = [
SKEncodedImageFormat.Png,
SKEncodedImageFormat.Webp,
SKEncodedImageFormat.Jpeg,
];
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;
protected int _quality = 100;
private string FormatSuffix
{
@@ -34,7 +38,7 @@ namespace SpineViewer.ViewModels.Exporters
{
if (_format == SKEncodedImageFormat.Heif) return ".jpeg";
else if (_format == SKEncodedImageFormat.Jpegxl) return ".jpeg";
else return $".{_format.ToString().ToLower()}";
else return $".{_format.ToString().ToLowerInvariant()}";
}
}

View File

@@ -333,7 +333,7 @@ namespace SpineViewer.ViewModels.MainWindow
{
foreach (var file in Directory.EnumerateFiles(_currentDirectory, "*.*", SearchOption.AllDirectories))
{
var lowerPath = file.ToLower();
var lowerPath = file.ToLowerInvariant();
if (SpineObject.PossibleSuffixMapping.Keys.Any(lowerPath.EndsWith))
_items.Add(new(file));
}

View File

@@ -32,15 +32,19 @@ namespace SpineViewer.ViewModels.MainWindow
/// <summary>
/// 指示是否通过托盘图标进行退出
/// </summary>
public bool IsShuttingDownFromTray => _isShuttingDownFromTray;
public bool IsShuttingDownFromTray
{
get => _isShuttingDownFromTray;
private set => SetProperty(ref _isShuttingDownFromTray, value);
}
private bool _isShuttingDownFromTray;
public bool? CloseToTray
public bool CloseToTray
{
get => _closeToTray;
set => SetProperty(ref _closeToTray, value);
}
private bool? _closeToTray = null;
private bool _closeToTray;
public string AutoRunWorkspaceConfigPath
{
@@ -109,8 +113,7 @@ namespace SpineViewer.ViewModels.MainWindow
public RelayCommand Cmd_ExitFromTray => _cmd_ExitFromTray ??= new(() =>
{
_isShuttingDownFromTray = true;
OnPropertyChanged(nameof(IsShuttingDownFromTray));
IsShuttingDownFromTray = true;
App.Current.Shutdown();
});
private RelayCommand? _cmd_ExitFromTray;

View File

@@ -295,7 +295,7 @@ namespace SpineViewer.ViewModels.MainWindow
set => SetProperty(_vmMain.SFMLRendererViewModel.WallpaperView, value, v => _vmMain.SFMLRendererViewModel.WallpaperView = v);
}
public bool? CloseToTray
public bool CloseToTray
{
get => _vmMain.CloseToTray;
set => SetProperty(_vmMain.CloseToTray, value, v => _vmMain.CloseToTray = v);

View File

@@ -34,6 +34,7 @@ namespace SpineViewer.ViewModels.MainWindow
/// 临时对象, 存储复制的模型参数
/// </summary>
private SpineObjectConfigModel? _copiedSpineObjectConfigModel = null;
private SpineObjectConfigApplyFlag _copiedConfigFlag = SpineObjectConfigApplyFlag.All;
public SpineObjectListViewModel(MainWindowViewModel mainViewModel)
{
@@ -99,6 +100,127 @@ namespace SpineViewer.ViewModels.MainWindow
}
}
/// <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.ToLowerInvariant();
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.ToLowerInvariant();
if (SpineObject.PossibleSuffixMapping.Keys.Any(lowerPath.EndsWith))
validPaths.Add(file);
}
}
}
if (validPaths.Count > 1)
{
if (validPaths.Count > 100)
{
if (!MessagePopupService.OKCancel(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)
{
InsertSpineObject(validPaths[0]);
_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}";
if (InsertSpineObject(skelPath))
success++;
else
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();
}
/// <summary>
/// 安全地在列表头添加一个模型, 发生错误会输出日志
/// </summary>
/// <returns>是否添加成功</returns>
private bool InsertSpineObject(string skelPath, string? atlasPath = null)
{
try
{
var sp = new SpineObjectModel(skelPath, atlasPath);
lock (_spineObjectModels.Lock) _spineObjectModels.Insert(0, sp);
if (Application.Current.Dispatcher.CheckAccess())
{
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset));
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Add, sp));
}
else
{
Application.Current.Dispatcher.Invoke(() =>
{
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset));
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Add, sp));
});
}
return true;
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to load: {0}, {1}", skelPath, ex.Message);
}
return false;
}
#region
/// <summary>
/// 弹窗添加单模型命令
/// </summary>
@@ -349,18 +471,53 @@ namespace SpineViewer.ViewModels.MainWindow
return true;
}
#endregion
#region
/// <summary>
/// 复制模型参数
/// </summary>
public RelayCommand<IList?> Cmd_CopySpineObjectConfig => _cmd_CopySpineObjectConfig ??= new(CopySpineObjectConfig_Execute, CopySpineObjectConfig_CanExecute);
public RelayCommand<IList?> Cmd_CopySpineObjectConfig => _cmd_CopySpineObjectConfig ??= new(
args => CopySpineObjectConfig_Execute(args, SpineObjectConfigApplyFlag.All),
CopySpineObjectConfig_CanExecute
);
private RelayCommand<IList?>? _cmd_CopySpineObjectConfig;
private void CopySpineObjectConfig_Execute(IList? args)
/// <summary>
/// 复制模型参数 (仅皮肤)
/// </summary>
public RelayCommand<IList?> Cmd_CopySpineObjectSkinConfig => _cmd_CopySpineObjectSkinConfig ??= new(
args => CopySpineObjectConfig_Execute(args, SpineObjectConfigApplyFlag.Skin),
CopySpineObjectConfig_CanExecute
);
private RelayCommand<IList?>? _cmd_CopySpineObjectSkinConfig;
/// <summary>
/// 复制模型参数 (仅插槽附件)
/// </summary>
public RelayCommand<IList?> Cmd_CopySpineObjectSlotAttachmentConfig => _cmd_CopySpineObjectSlotAttachmentConfig ??= new(
args => CopySpineObjectConfig_Execute(args, SpineObjectConfigApplyFlag.SlotAttachement),
CopySpineObjectConfig_CanExecute
);
private RelayCommand<IList?>? _cmd_CopySpineObjectSlotAttachmentConfig;
/// <summary>
/// 复制模型参数 (仅插槽可见性)
/// </summary>
public RelayCommand<IList?> Cmd_CopySpineObjectSlotVisibilityConfig => _cmd_CopySpineObjectSlotVisibilityConfig ??= new(
args => CopySpineObjectConfig_Execute(args, SpineObjectConfigApplyFlag.SlotVisibility),
CopySpineObjectConfig_CanExecute
);
private RelayCommand<IList?>? _cmd_CopySpineObjectSlotVisibilityConfig;
private void CopySpineObjectConfig_Execute(IList? args, SpineObjectConfigApplyFlag flag)
{
if (!CopySpineObjectConfig_CanExecute(args)) return;
var sp = (SpineObjectModel)args[0];
_copiedSpineObjectConfigModel = sp.ObjectConfig;
_logger.Info("Copy config from model: {0}", sp.Name);
_copiedConfigFlag = flag;
_logger.Info("Copy config[{0}] from model: {1}", flag, sp.Name);
}
private bool CopySpineObjectConfig_CanExecute(IList? args)
@@ -381,8 +538,8 @@ namespace SpineViewer.ViewModels.MainWindow
if (!ApplySpineObjectConfig_CanExecute(args)) return;
foreach (SpineObjectModel sp in args)
{
sp.ObjectConfig = _copiedSpineObjectConfigModel;
_logger.Info("Apply config to model: {0}", sp.Name);
sp.ApplyObjectConfig(_copiedSpineObjectConfigModel, _copiedConfigFlag);
_logger.Info("Apply config[{0}] to model: {1}", _copiedConfigFlag, sp.Name);
}
}
@@ -439,124 +596,9 @@ namespace SpineViewer.ViewModels.MainWindow
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);
}
}
}
#endregion
if (validPaths.Count > 1)
{
if (validPaths.Count > 100)
{
if (!MessagePopupService.OKCancel(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)
{
InsertSpineObject(validPaths[0]);
_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}";
if (InsertSpineObject(skelPath))
success++;
else
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();
}
/// <summary>
/// 安全地在列表头添加一个模型, 发生错误会输出日志
/// </summary>
/// <returns>是否添加成功</returns>
private bool InsertSpineObject(string skelPath, string? atlasPath = null)
{
try
{
var sp = new SpineObjectModel(skelPath, atlasPath);
lock (_spineObjectModels.Lock) _spineObjectModels.Insert(0, sp);
if (Application.Current.Dispatcher.CheckAccess())
{
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset));
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Add, sp));
}
else
{
Application.Current.Dispatcher.Invoke(() =>
{
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset));
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Add, sp));
});
}
return true;
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to load: {0}, {1}", skelPath, ex.Message);
}
return false;
}
#region
public List<SpineObjectWorkspaceConfigModel> LoadedSpineObjects
{
@@ -681,5 +723,7 @@ namespace SpineViewer.ViewModels.MainWindow
}
return false;
}
#endregion
}
}

View File

@@ -346,6 +346,18 @@ namespace SpineViewer.ViewModels.MainWindow
);
private RelayCommand<IList?> _cmd_DisableSkins;
public RelayCommand Cmd_EnableAllSkins => _cmd_EnableAllSkins ??= new(
() => { if (_skins.Count <= 0) return; foreach (var s in _skins) s.Status = true; },
() => { return _skins.Count > 0; }
);
private RelayCommand _cmd_EnableAllSkins;
public RelayCommand Cmd_DisableAllSkins => _cmd_DisableAllSkins ??= new(
() => { if (_skins.Count <= 0) return; foreach (var s in _skins) s.Status = false; },
() => { return _skins.Count > 0; }
);
private RelayCommand _cmd_DisableAllSkins;
public ObservableCollection<SlotViewModel> Slots => _slots;
public RelayCommand<IList?> Cmd_EnableSlots => _cmd_EnableSlots ??= new (
@@ -360,6 +372,18 @@ namespace SpineViewer.ViewModels.MainWindow
);
private RelayCommand<IList?> _cmd_DisableSlots;
public RelayCommand Cmd_EnableAllSlots => _cmd_EnableAllSlots ??= new(
() => { if (_slots.Count <= 0) return; foreach (var s in _slots) s.Visible = true; },
() => { return _slots.Count > 0; }
);
private RelayCommand _cmd_EnableAllSlots;
public RelayCommand Cmd_DisableAllSlots => _cmd_DisableAllSlots ??= new(
() => { if (_slots.Count <= 0) return; foreach (var s in _slots) s.Visible = false; },
() => { return _slots.Count > 0; }
);
private RelayCommand _cmd_DisableAllSlots;
public ObservableCollection<AnimationTrackViewModel> AnimationTracks => _animationTracks;
public RelayCommand Cmd_AppendTrack => _cmd_AppendTrack ??= new(

View File

@@ -247,8 +247,11 @@
<ColumnDefinition Width="Auto" SharedSizeGroup="LabelCol"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Label Content="{DynamicResource Str_ApngPred}" ToolTip="{DynamicResource Str_ApngPredTooltip}"/>
<TextBox Grid.Column="1" Text="{Binding ApngPred}" ToolTip="{DynamicResource Str_ApngPredTooltip}"/>
<Label Content="{DynamicResource Str_PredMethod}" ToolTip="{DynamicResource Str_PredMethodTooltip}"/>
<ComboBox Grid.Column="1"
SelectedItem="{Binding PredMethod}"
ItemsSource="{x:Static vmexp:FFmpegVideoExporterViewModel.ApngPredMethodOptions}"
ToolTip="{DynamicResource Str_PredMethodTooltip}"/>
</Grid>
<!-- CRF 参数 -->
@@ -268,7 +271,10 @@
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Label Content="{DynamicResource Str_ProfileParameter}" ToolTip="{DynamicResource Str_ProfileParameterTooltip}"/>
<TextBox Grid.Column="1" Text="{Binding Profile}" ToolTip="{DynamicResource Str_ProfileParameterTooltip}"/>
<ComboBox Grid.Column="1"
SelectedItem="{Binding Profile}"
ItemsSource="{x:Static vmexp:FFmpegVideoExporterViewModel.MovProfileOptions}"
ToolTip="{DynamicResource Str_ProfileParameterTooltip}"/>
</Grid>
</StackPanel>
</GroupBox>

View File

@@ -9,12 +9,12 @@
xmlns:SFMLRenderer="clr-namespace:SFMLRenderer;assembly=SFMLRenderer"
mc:Ignorable="d"
x:Name="_mainWindow"
Title="{Binding Title}"
Background="{DynamicResource RegionBrush}"
Width="1500"
Height="800"
WindowStartupLocation="CenterScreen"
d:DataContext="{d:DesignInstance Type={x:Type vm:MainWindowViewModel}}"
Title="{Binding Title}"
Width="1280"
Height="720"
Background="{DynamicResource RegionBrush}"
WindowStartupLocation="CenterScreen"
PreviewKeyDown="MainWindow_PreviewKeyDown"
LocationChanged="MainWindow_LocationChanged"
SizeChanged="MainWindow_SizeChanged">
@@ -73,7 +73,7 @@
<Border Grid.Row="1">
<Grid x:Name="_rootGrid">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="2.5*"/>
</Grid.ColumnDefinitions>
@@ -85,7 +85,15 @@
<TabControl x:Name="_mainTabControl" TabStripPlacement="Left">
<!-- 模型列表页 -->
<TabItem Header="{DynamicResource Str_SpineObject}">
<TabItem>
<TabItem.Header>
<Border Style="{StaticResource MyTabItemHeaderContainerStyle}"
MouseLeftButtonDown="MainTabControlHeader_MouseLeftButtonDown">
<Viewbox Width="24" Height="24" ToolTip="{DynamicResource Str_SpineObject}">
<Path Data="{StaticResource Geo_Cubes}" Style="{StaticResource MyTabItemHeaderPathStyle}"/>
</Viewbox>
</Border>
</TabItem.Header>
<Grid x:Name="_modelListGrid">
<Grid.RowDefinitions>
<RowDefinition/>
@@ -162,21 +170,32 @@
Command="{Binding Cmd_MoveDownSpineObject}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<Separator/>
<MenuItem Header="{DynamicResource Str_CopySpineObjectConfig}"
<MenuItem Header="{DynamicResource Str_SpineObjectConfig}">
<MenuItem Header="{DynamicResource Str_CopySpineObjectConfig}"
InputGestureText="Ctrl+Shift+C"
Command="{Binding Cmd_CopySpineObjectConfig}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<MenuItem Header="{DynamicResource Str_ApplySpineObjectConfig}"
<MenuItem Header="{DynamicResource Str_CopySpineObjectSkinConfig}"
Command="{Binding Cmd_CopySpineObjectSkinConfig}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<MenuItem Header="{DynamicResource Str_CopySpineObjectSlotAttachmentConfig}"
Command="{Binding Cmd_CopySpineObjectSlotAttachmentConfig}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<MenuItem Header="{DynamicResource Str_CopySpineObjectSlotVisibilityConfig}"
Command="{Binding Cmd_CopySpineObjectSlotVisibilityConfig}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<MenuItem Header="{DynamicResource Str_ApplySpineObjectConfig}"
InputGestureText="Ctrl+Shift+V"
Command="{Binding Cmd_ApplySpineObjectConfig}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<MenuItem Header="{DynamicResource Str_ApplySpineObjectConfigFromFile}"
<Separator/>
<MenuItem Header="{DynamicResource Str_ApplySpineObjectConfigFromFile}"
Command="{Binding Cmd_ApplySpineObjectConfigFromFile}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<MenuItem Header="{DynamicResource Str_SaveSpineObjectConfigToFile}"
<MenuItem Header="{DynamicResource Str_SaveSpineObjectConfigToFile}"
Command="{Binding Cmd_SaveSpineObjectConfigToFile}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<Separator/>
</MenuItem>
<MenuItem Header="{DynamicResource Str_Export}">
<MenuItem Header="{DynamicResource Str_ExportFrame}"
Command="{Binding FrameExporterViewModel.Cmd_Export}"
@@ -429,12 +448,14 @@
<ListBox ItemsSource="{Binding Skins}">
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="{DynamicResource Str_EnableSkins}"
<MenuItem Header="{DynamicResource Str_Enable}"
Command="{Binding Cmd_EnableSkins}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<MenuItem Header="{DynamicResource Str_DisableSkins}"
<MenuItem Header="{DynamicResource Str_Disable}"
Command="{Binding Cmd_DisableSkins}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<MenuItem Header="{DynamicResource Str_EnableAll}" Command="{Binding Cmd_EnableAllSkins}"/>
<MenuItem Header="{DynamicResource Str_DisableAll}" Command="{Binding Cmd_DisableAllSkins}"/>
</ContextMenu>
</ListBox.ContextMenu>
@@ -460,12 +481,14 @@
<ListBox ItemsSource="{Binding Slots}" Grid.IsSharedSizeScope="True">
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="{DynamicResource Str_EnableSlots}"
<MenuItem Header="{DynamicResource Str_Enable}"
Command="{Binding Cmd_EnableSlots}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<MenuItem Header="{DynamicResource Str_DisableSlots}"
<MenuItem Header="{DynamicResource Str_Disable}"
Command="{Binding Cmd_DisableSlots}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<MenuItem Header="{DynamicResource Str_EnableAll}" Command="{Binding Cmd_EnableAllSlots}"/>
<MenuItem Header="{DynamicResource Str_DisableAll}" Command="{Binding Cmd_DisableAllSlots}"/>
</ContextMenu>
</ListBox.ContextMenu>
@@ -660,7 +683,15 @@
</TabItem>
<!-- 浏览页 -->
<TabItem Header="{DynamicResource Str_Explorer}" DataContext="{Binding ExplorerListViewModel}">
<TabItem DataContext="{Binding ExplorerListViewModel}">
<TabItem.Header>
<Border Style="{StaticResource MyTabItemHeaderContainerStyle}"
MouseLeftButtonDown="MainTabControlHeader_MouseLeftButtonDown">
<Viewbox Width="24" Height="24" ToolTip="{DynamicResource Str_Explorer}">
<Path Data="{StaticResource Geo_Image}" Style="{StaticResource MyTabItemHeaderPathStyle}"/>
</Viewbox>
</Border>
</TabItem.Header>
<Grid x:Name="_explorerGrid">
<Grid.RowDefinitions>
<RowDefinition/>
@@ -771,7 +802,15 @@
</TabItem>
<!-- 画面参数页 -->
<TabItem Header="{DynamicResource Str_Canvas}" DataContext="{Binding SFMLRendererViewModel}">
<TabItem DataContext="{Binding SFMLRendererViewModel}">
<TabItem.Header>
<Border Style="{StaticResource MyTabItemHeaderContainerStyle}"
MouseLeftButtonDown="MainTabControlHeader_MouseLeftButtonDown">
<Viewbox Width="24" Height="24" ToolTip="{DynamicResource Str_Canvas}" >
<Path Data="{StaticResource Geo_Sliders}" Style="{StaticResource MyTabItemHeaderPathStyle}"/>
</Viewbox>
</Border>
</TabItem.Header>
<TabItem.Resources>
<Style TargetType="{x:Type Label}" BasedOn="{StaticResource LabelDefault}">
<Setter Property="HorizontalAlignment" Value="Stretch"/>

View File

@@ -1,6 +1,4 @@
using NLog;
using NLog.Layouts;
using NLog.Targets;
using SFMLRenderer;
using Spine;
using SpineViewer.Models;
@@ -25,6 +23,7 @@ using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Threading;
namespace SpineViewer.Views;
@@ -36,7 +35,7 @@ public partial class MainWindow : Window
/// <summary>
/// 上一次状态文件保存路径
/// </summary>
public static readonly string LastStateFilePath = Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), "laststate.json");
public static readonly string UserStateFilePath = Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), "userstate.json");
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
@@ -46,6 +45,41 @@ public partial class MainWindow : Window
private readonly SFMLRenderWindow _wallpaperRenderWindow;
private readonly MainWindowViewModel _vm;
private readonly List<IDisposable> _userStateWatchers = [];
private DispatcherTimer _saveUserStateTimer;
private readonly TimeSpan _saveTimerDelay = TimeSpan.FromSeconds(3);
public bool RootGridCol0Folded
{
get => ((ContentPresenter)_mainTabControl.Template.FindName("PART_SelectedContentHost", _mainTabControl)).Visibility != Visibility.Visible;
set
{
var mainTabContentHost = (ContentPresenter)_mainTabControl.Template.FindName("PART_SelectedContentHost", _mainTabControl);
if ((mainTabContentHost.Visibility != Visibility.Visible) == value)
return;
if (value)
{
// 寄存折叠前的宽度比例
_rootGrid.ColumnDefinitions[0].Tag = _rootGrid.ColumnDefinitions[0].Width;
_rootGrid.ColumnDefinitions[1].Tag = _rootGrid.ColumnDefinitions[1].Width;
_rootGrid.ColumnDefinitions[2].Tag = _rootGrid.ColumnDefinitions[2].Width;
// 进行折叠
mainTabContentHost.Visibility = Visibility.Collapsed;
_rootGrid.ColumnDefinitions[0].Width = GridLength.Auto;
_rootGrid.ColumnDefinitions[1].Width = new(0);
}
else
{
// 解除折叠
_rootGrid.ColumnDefinitions[0].Width = (GridLength)_rootGrid.ColumnDefinitions[0].Tag;
_rootGrid.ColumnDefinitions[1].Width = (GridLength)_rootGrid.ColumnDefinitions[1].Tag;
_rootGrid.ColumnDefinitions[2].Width = (GridLength)_rootGrid.ColumnDefinitions[2].Tag;
mainTabContentHost.Visibility = Visibility.Visible;
}
}
}
public MainWindow()
{
InitializeComponent();
@@ -86,11 +120,11 @@ public partial class MainWindow : Window
var rtbTarget = new NLog.Windows.Wpf.RichTextBoxTarget
{
Name = "rtbTarget",
Layout = "[${level:format=OneLetter}]${date:format=yyyy-MM-dd HH\\:mm\\:ss} - ${message}",
WindowName = _mainWindow.Name,
ControlName = _loggerRichTextBox.Name,
AutoScroll = true,
MaxLines = 3000,
Layout = "[${level:format=OneLetter}]${date:format=yyyy-MM-dd HH\\:mm\\:ss} - ${message}",
};
rtbTarget.WordColoringRules.Add(new("[D]", "Gray", "Empty"));
@@ -104,79 +138,6 @@ public partial class MainWindow : Window
LogManager.ReconfigExistingLoggers();
}
private void LoadLastState()
{
if (JsonHelper.Deserialize<LastStateModel>(LastStateFilePath, out var m, true))
{
Left = m.WindowLeft;
Top = m.WindowTop;
Width = m.WindowWidth;
Height = m.WindowHeight;
if (m.WindowState == WindowState.Maximized)
{
WindowState = WindowState.Maximized;
}
else
{
WindowState = WindowState.Normal;
}
_rootGrid.ColumnDefinitions[0].Width = new(m.RootGridCol0Width, GridUnitType.Star);
_rootGrid.ColumnDefinitions[2].Width = new(m.RootGridCol2Width, GridUnitType.Star);
_modelListGrid.RowDefinitions[0].Height = new(m.ModelListRow0Height, GridUnitType.Star);
_modelListGrid.RowDefinitions[2].Height = new(m.ModelListRow2Height, GridUnitType.Star);
_explorerGrid.RowDefinitions[0].Height = new(m.ExplorerGridRow0Height, GridUnitType.Star);
_explorerGrid.RowDefinitions[2].Height = new(m.ExplorerGridRow2Height, GridUnitType.Star);
_rightPanelGrid.RowDefinitions[0].Height = new(m.RightPanelGridRow0Height, GridUnitType.Star);
_rightPanelGrid.RowDefinitions[2].Height = new(m.RightPanelGridRow2Height, GridUnitType.Star);
_vm.SFMLRendererViewModel.SetResolution(m.ResolutionX, m.ResolutionY);
_vm.SFMLRendererViewModel.MaxFps = m.MaxFps;
_vm.SFMLRendererViewModel.Speed = m.Speed;
_vm.SFMLRendererViewModel.ShowAxis = m.ShowAxis;
_vm.SFMLRendererViewModel.BackgroundColor = m.BackgroundColor;
_vm.SFMLRendererViewModel.BackgroundImageMode = m.BackgroundImageMode;
}
}
private void SaveLastState()
{
var rb = RestoreBounds;
var m = new LastStateModel()
{
WindowLeft = rb.Left,
WindowTop = rb.Top,
WindowWidth = rb.Width,
WindowHeight = rb.Height,
WindowState = WindowState,
RootGridCol0Width = _rootGrid.ColumnDefinitions[0].Width.Value,
RootGridCol2Width = _rootGrid.ColumnDefinitions[2].Width.Value,
ModelListRow0Height = _modelListGrid.RowDefinitions[0].Height.Value,
ModelListRow2Height = _modelListGrid.RowDefinitions[2].Height.Value,
ExplorerGridRow0Height = _explorerGrid.RowDefinitions[0].Height.Value,
ExplorerGridRow2Height = _explorerGrid.RowDefinitions[2].Height.Value,
RightPanelGridRow0Height = _rightPanelGrid.RowDefinitions[0].Height.Value,
RightPanelGridRow2Height = _rightPanelGrid.RowDefinitions[2].Height.Value,
ResolutionX = _vm.SFMLRendererViewModel.ResolutionX,
ResolutionY = _vm.SFMLRendererViewModel.ResolutionY,
MaxFps = _vm.SFMLRendererViewModel.MaxFps,
Speed = _vm.SFMLRendererViewModel.Speed,
ShowAxis = _vm.SFMLRendererViewModel.ShowAxis,
BackgroundColor = _vm.SFMLRendererViewModel.BackgroundColor,
BackgroundImageMode = _vm.SFMLRendererViewModel.BackgroundImageMode,
};
JsonHelper.Serialize(m, LastStateFilePath);
}
#region MainWindow
private void MainWindow_SourceInitialized(object? sender, EventArgs e)
@@ -206,7 +167,29 @@ public partial class MainWindow : Window
// 加载首选项
_vm.PreferenceViewModel.LoadPreference();
LoadLastState();
// 还原上一次用户历史状态
LoadUserState();
// 添加用户状态监听器
_userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.WidthProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.LeftProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.TopProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.WindowStateProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_rootGrid.ColumnDefinitions[0], ColumnDefinition.WidthProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_rootGrid.ColumnDefinitions[2], ColumnDefinition.WidthProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_modelListGrid.RowDefinitions[0], RowDefinition.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_modelListGrid.RowDefinitions[2], RowDefinition.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_explorerGrid.RowDefinitions[0], RowDefinition.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_explorerGrid.RowDefinitions[2], RowDefinition.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_rightPanelGrid.RowDefinitions[0], RowDefinition.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_rightPanelGrid.RowDefinitions[2], RowDefinition.HeightProperty, DelayedSaveUserState));
_vm.SFMLRendererViewModel.PropertyChanged += SFMLRendererUserStateChanged;
}
private void MainWindow_ContentRendered(object? sender, EventArgs e)
@@ -238,11 +221,6 @@ public partial class MainWindow : Window
{
if (!_vm.IsShuttingDownFromTray)
{
if (_vm.CloseToTray is null)
{
_vm.PreferenceViewModel.CloseToTray = MessagePopupService.YesNo(AppResource.Str_CloseToTrayQuest);
_vm.PreferenceViewModel.SavePreference();
}
if (_vm.CloseToTray is true)
{
Hide();
@@ -251,7 +229,14 @@ public partial class MainWindow : Window
}
}
SaveLastState();
// 保存当前用户状态
SaveUserState();
// 撤除所有状态监听器
_vm.SFMLRendererViewModel.PropertyChanged -= SFMLRendererUserStateChanged;
foreach (var w in _userStateWatchers) w.Dispose();
_userStateWatchers.Clear();
_vm.SFMLRendererViewModel.StopRender();
}
@@ -260,6 +245,135 @@ public partial class MainWindow : Window
}
private void LoadUserState()
{
if (JsonHelper.Deserialize<UserStateModel>(UserStateFilePath, out var m, true))
{
Left = m.WindowLeft;
Top = m.WindowTop;
Width = m.WindowWidth;
Height = m.WindowHeight;
if (m.WindowState == WindowState.Maximized)
{
WindowState = WindowState.Maximized;
}
else
{
WindowState = WindowState.Normal;
}
if (m.RootGridCol0Folded)
{
RootGridCol0Folded = true;
_rootGrid.ColumnDefinitions[0].Tag = new GridLength(m.RootGridCol0Width, GridUnitType.Star);
_rootGrid.ColumnDefinitions[2].Tag = new GridLength(m.RootGridCol2Width, GridUnitType.Star);
}
else
{
RootGridCol0Folded = false;
_rootGrid.ColumnDefinitions[0].Width = new(m.RootGridCol0Width, GridUnitType.Star);
_rootGrid.ColumnDefinitions[2].Width = new(m.RootGridCol2Width, GridUnitType.Star);
}
_modelListGrid.RowDefinitions[0].Height = new(m.ModelListRow0Height, GridUnitType.Star);
_modelListGrid.RowDefinitions[2].Height = new(m.ModelListRow2Height, GridUnitType.Star);
_explorerGrid.RowDefinitions[0].Height = new(m.ExplorerGridRow0Height, GridUnitType.Star);
_explorerGrid.RowDefinitions[2].Height = new(m.ExplorerGridRow2Height, GridUnitType.Star);
_rightPanelGrid.RowDefinitions[0].Height = new(m.RightPanelGridRow0Height, GridUnitType.Star);
_rightPanelGrid.RowDefinitions[2].Height = new(m.RightPanelGridRow2Height, GridUnitType.Star);
_vm.SFMLRendererViewModel.SetResolution(m.ResolutionX, m.ResolutionY);
_vm.SFMLRendererViewModel.MaxFps = m.MaxFps;
_vm.SFMLRendererViewModel.Speed = m.Speed;
_vm.SFMLRendererViewModel.ShowAxis = m.ShowAxis;
_vm.SFMLRendererViewModel.BackgroundColor = m.BackgroundColor;
_vm.SFMLRendererViewModel.BackgroundImageMode = m.BackgroundImageMode;
}
}
private void SaveUserState()
{
var rb = RestoreBounds;
var m = new UserStateModel()
{
WindowLeft = rb.Left,
WindowTop = rb.Top,
WindowWidth = rb.Width,
WindowHeight = rb.Height,
WindowState = WindowState,
RootGridCol0Folded = RootGridCol0Folded,
RootGridCol0Width = _rootGrid.ColumnDefinitions[0].Width.Value,
RootGridCol2Width = _rootGrid.ColumnDefinitions[2].Width.Value,
ModelListRow0Height = _modelListGrid.RowDefinitions[0].Height.Value,
ModelListRow2Height = _modelListGrid.RowDefinitions[2].Height.Value,
ExplorerGridRow0Height = _explorerGrid.RowDefinitions[0].Height.Value,
ExplorerGridRow2Height = _explorerGrid.RowDefinitions[2].Height.Value,
RightPanelGridRow0Height = _rightPanelGrid.RowDefinitions[0].Height.Value,
RightPanelGridRow2Height = _rightPanelGrid.RowDefinitions[2].Height.Value,
ResolutionX = _vm.SFMLRendererViewModel.ResolutionX,
ResolutionY = _vm.SFMLRendererViewModel.ResolutionY,
MaxFps = _vm.SFMLRendererViewModel.MaxFps,
Speed = _vm.SFMLRendererViewModel.Speed,
ShowAxis = _vm.SFMLRendererViewModel.ShowAxis,
BackgroundColor = _vm.SFMLRendererViewModel.BackgroundColor,
BackgroundImageMode = _vm.SFMLRendererViewModel.BackgroundImageMode,
};
if (m.RootGridCol0Folded)
{
m.RootGridCol0Width = ((GridLength)_rootGrid.ColumnDefinitions[0].Tag).Value;
m.RootGridCol2Width = ((GridLength)_rootGrid.ColumnDefinitions[2].Tag).Value;
}
JsonHelper.Serialize(m, UserStateFilePath);
}
/// <summary>
/// <see cref="SaveUserState"/> 的延时版本, 避免一次性大量执行
/// </summary>
private void DelayedSaveUserState()
{
// 第一次调用时创建定时器
if (_saveUserStateTimer == null)
{
_saveUserStateTimer = new() { Interval = _saveTimerDelay };
_saveUserStateTimer.Tick += (s, e) =>
{
_saveUserStateTimer.Stop();
SaveUserState();
};
}
// 每次触发都重置间隔和计时
_saveUserStateTimer.Stop();
_saveUserStateTimer.Start();
}
private void SFMLRendererUserStateChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(SFMLRendererViewModel.ResolutionX):
case nameof(SFMLRendererViewModel.ResolutionY):
case nameof(SFMLRendererViewModel.MaxFps):
case nameof(SFMLRendererViewModel.Speed):
case nameof(SFMLRendererViewModel.ShowAxis):
case nameof(SFMLRendererViewModel.BackgroundColor):
case nameof(SFMLRendererViewModel.BackgroundImageMode):
DelayedSaveUserState();
break;
default:
break;
}
}
#endregion
#region ColorPicker
@@ -321,6 +435,34 @@ public partial class MainWindow : Window
#endregion
#region _mainTabControl
private void MainTabControlHeader_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
// 全屏状态忽略该功能
if (_fullScreenLayout.Visibility == Visibility.Visible)
return;
if (sender is not FrameworkElement fe)
return;
// 找到包含这个 Border 的 TabItem
var tabItem = VisualFindParent<TabItem>(fe);
if (tabItem is null)
return;
if (_mainTabControl.SelectedItem == tabItem)
{
RootGridCol0Folded = !RootGridCol0Folded;
}
else
{
RootGridCol0Folded = false;
}
}
#endregion
#region _spinesListView
private void SpinesListView_RequestSelectionChanging(object? sender, NotifyCollectionChangedEventArgs e)
@@ -440,13 +582,6 @@ public partial class MainWindow : Window
e.Handled = true;
}
private static T? VisualUpwardSearch<T>(DependencyObject? source) where T : DependencyObject
{
while (source != null && source is not T)
source = VisualTreeHelper.GetParent(source);
return source as T;
}
#endregion
#region _spineFilesListBox
@@ -487,6 +622,8 @@ public partial class MainWindow : Window
if (_fullScreenLayout.Visibility == Visibility.Visible) return;
RootGridCol0Folded = false; // 取消折叠
IntPtr hwnd = new WindowInteropHelper(this).Handle;
if (User32.GetScreenResolution(hwnd, out var resX, out var resY))
{
@@ -736,7 +873,16 @@ public partial class MainWindow : Window
}
#endregion
private static T? VisualUpwardSearch<T>(DependencyObject? source) where T : DependencyObject
{
while (source != null && source is not T)
source = VisualTreeHelper.GetParent(source);
return source as T;
}
public static T? VisualFindParent<T>(DependencyObject child) where T : DependencyObject
=> VisualUpwardSearch<T>(VisualTreeHelper.GetParent(child));
private void DebugMenuItem_Click(object sender, RoutedEventArgs e)
{
@@ -749,4 +895,5 @@ public partial class MainWindow : Window
return;
#endif
}
}

View File

@@ -0,0 +1,217 @@
using SkiaSharp;
using Spectre.Console;
using Spectre.Console.Rendering;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewerCLI
{
public class CanvasAscii : Renderable
{
private readonly SKColor?[,] _pixels;
/// <summary>
/// Gets the width of the canvas.
/// </summary>
public int Width { get; }
/// <summary>
/// Gets the height of the canvas.
/// </summary>
public int Height { get; }
/// <summary>
/// Gets or sets the render width of the canvas.
/// </summary>
public int? MaxWidth { get; set; }
/// <summary>
/// Gets or sets a value indicating whether or not
/// to scale the canvas when rendering.
/// </summary>
public bool Scale { get; set; } = true;
/// <summary>
/// Gets or sets the pixel width.
/// </summary>
public int PixelWidth { get; set; } = 2;
/// <summary>
/// Gets or sets the pixel characters, ordered by transparency.
/// </summary>
public string PixelCharacters { get; set; } = ".,:;-=+*?oSXBGWM$&%#@";
/// <summary>
/// Whether to use pixel characters instead of spaces.
/// </summary>
public bool UsePixelCharacters { get; set; } = false;
/// <summary>
/// Initializes a new instance of the <see cref="CanvasAscii"/> class.
/// </summary>
/// <param name="width">The canvas width.</param>
/// <param name="height">The canvas height.</param>
public CanvasAscii(int width, int height)
{
if (width < 1)
{
throw new ArgumentException("Must be > 1", nameof(width));
}
if (height < 1)
{
throw new ArgumentException("Must be > 1", nameof(height));
}
Width = width;
Height = height;
_pixels = new SKColor?[Width, Height];
}
/// <summary>
/// Sets a pixel with the specified color in the canvas at the specified location.
/// </summary>
/// <param name="x">The X coordinate for the pixel.</param>
/// <param name="y">The Y coordinate for the pixel.</param>
/// <param name="color">The pixel color.</param>
/// <returns>The same <see cref="CanvasAscii"/> instance so that multiple calls can be chained.</returns>
public CanvasAscii SetPixel(int x, int y, SKColor color)
{
_pixels[x, y] = color;
return this;
}
/// <inheritdoc/>
protected override Measurement Measure(RenderOptions options, int maxWidth)
{
if (PixelWidth < 0)
{
throw new InvalidOperationException("Pixel width must be greater than zero.");
}
var width = MaxWidth ?? Width;
if (maxWidth < width * PixelWidth)
{
return new Measurement(maxWidth, maxWidth);
}
return new Measurement(width * PixelWidth, width * PixelWidth);
}
/// <inheritdoc/>
protected override IEnumerable<Segment> Render(RenderOptions options, int maxWidth)
{
if (PixelWidth < 0)
{
throw new InvalidOperationException("Pixel width must be greater than zero.");
}
if (UsePixelCharacters && PixelCharacters.Length <= 0)
{
throw new InvalidOperationException("Pixel letters can't be empty.");
}
var pixels = _pixels;
var emptyPixel = new string(' ', PixelWidth);
var width = Width;
var height = Height;
// Got a max width?
if (MaxWidth != null)
{
height = (int)(height * ((float)MaxWidth.Value) / Width);
width = MaxWidth.Value;
}
// Exceed the max width when we take pixel width into account?
if (width * PixelWidth > maxWidth)
{
height = (int)(height * (maxWidth / (float)(width * PixelWidth)));
width = maxWidth / PixelWidth;
// If it's not possible to scale the canvas sufficiently, it's too small to render.
if (height == 0)
{
yield break;
}
}
// Need to rescale the pixel buffer?
if (Scale && (width != Width || height != Height))
{
pixels = ScaleDown(width, height);
}
if (UsePixelCharacters)
{
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
var color = pixels[x, y];
if (color.HasValue)
{
var c = color.Value;
yield return new Segment(GetPixelChar(c), new Style(foreground: new(c.Red, c.Green, c.Blue)));
}
else
{
yield return new Segment(emptyPixel);
}
}
yield return Segment.LineBreak;
}
}
else
{
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
var color = pixels[x, y];
if (color.HasValue)
{
var c = color.Value;
yield return new Segment(emptyPixel, new Style(background: new(c.Red, c.Green, c.Blue)));
}
else
{
yield return new Segment(emptyPixel);
}
}
yield return Segment.LineBreak;
}
}
}
private SKColor?[,] ScaleDown(int newWidth, int newHeight)
{
var buffer = new SKColor?[newWidth, newHeight];
var xRatio = ((Width << 16) / newWidth) + 1;
var yRatio = ((Height << 16) / newHeight) + 1;
for (var i = 0; i < newHeight; i++)
{
for (var j = 0; j < newWidth; j++)
{
buffer[j, i] = _pixels[(j * xRatio) >> 16, (i * yRatio) >> 16];
}
}
return buffer;
}
private string GetPixelChar(SKColor c)
{
var index = Math.Min((int)(c.Alpha / 255f * PixelCharacters.Length), PixelCharacters.Length - 1);
return new(PixelCharacters[index], PixelWidth);
}
}
}

View File

@@ -0,0 +1,163 @@
using SkiaSharp;
using Spectre.Console;
using Spectre.Console.Rendering;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewerCLI
{
internal class CanvasImageAscii : Renderable
{
private static readonly SKSamplingOptions _defaultSamplingOptions = new(new SKCubicResampler());
/// <summary>
/// Gets the image width.
/// </summary>
public int Width => Image.Width;
/// <summary>
/// Gets the image height.
/// </summary>
public int Height => Image.Height;
/// <summary>
/// Gets or sets the render width of the canvas.
/// </summary>
public int? MaxWidth { get; set; }
/// <summary>
/// Gets or sets the render width of the canvas.
/// </summary>
public int PixelWidth { get; set; } = 2;
/// <summary>
/// Gets or sets the pixel characters, ordered by transparency.
/// </summary>
public string PixelCharacters { get; set; } = ".,:;-=+*?oSXBGWM$&%#@";
/// <summary>
/// Whether to use pixel characters instead of spaces.
/// </summary>
public bool UsePixelCharacters { get; set; } = false;
/// <summary>
/// Gets or sets the <see cref="SKSamplingOptions"/> that should
/// be used when scaling the image. Defaults to bicubic sampling.
/// </summary>
public SKSamplingOptions? SamplingOptions { get; set; }
internal SKBitmap Image { get; }
/// <summary>
/// Initializes a new instance of the <see cref="CanvasImageAscii"/> class.
/// </summary>
/// <param name="filename">The image filename.</param>
public CanvasImageAscii(string filename)
{
Image = SKBitmap.Decode(filename);
}
/// <summary>
/// Initializes a new instance of the <see cref="CanvasImageAscii"/> class.
/// </summary>
/// <param name="data">Buffer containing an image.</param>
public CanvasImageAscii(ReadOnlySpan<byte> data)
{
Image = SKBitmap.Decode(data);
}
/// <summary>
/// Initializes a new instance of the <see cref="CanvasImageAscii"/> class.
/// </summary>
/// <param name="data">Stream containing an image.</param>
public CanvasImageAscii(Stream data)
{
Image = SKBitmap.Decode(data);
}
/// <summary>
/// Initializes a new instance of the <see cref="CanvasImageAscii"/> class.
/// </summary>
/// <param name="image">The <see cref="SKImage"/> object.</param>
public CanvasImageAscii(SKImage image)
{
Image = SKBitmap.FromImage(image);
}
/// <inheritdoc/>
protected override Measurement Measure(RenderOptions options, int maxWidth)
{
if (PixelWidth < 0)
{
throw new InvalidOperationException("Pixel width must be greater than zero.");
}
var width = MaxWidth ?? Width;
if (maxWidth < width * PixelWidth)
{
return new Measurement(maxWidth, maxWidth);
}
return new Measurement(width * PixelWidth, width * PixelWidth);
}
/// <inheritdoc/>
protected override IEnumerable<Segment> Render(RenderOptions options, int maxWidth)
{
var image = Image;
var width = Width;
var height = Height;
// Got a max width?
if (MaxWidth != null)
{
height = (int)(height * ((float)MaxWidth.Value) / Width);
width = MaxWidth.Value;
}
// Exceed the max width when we take pixel width into account?
if (width * PixelWidth > maxWidth)
{
height = (int)(height * (maxWidth / (float)(width * PixelWidth)));
width = maxWidth / PixelWidth;
}
// Need to rescale the pixel buffer?
if (width != Width || height != Height)
{
var samplingOptions = SamplingOptions ?? _defaultSamplingOptions;
image = image.Resize(new SKSizeI(width, height), samplingOptions);
}
var canvas = new CanvasAscii(width, height)
{
MaxWidth = MaxWidth,
PixelWidth = PixelWidth,
PixelCharacters = PixelCharacters,
UsePixelCharacters = UsePixelCharacters,
Scale = false,
};
// XXX: 也许是 SkiaSharp@3.119.0 的 bug, 此处像素值一定是非预乘的格式
for (var y = 0; y < image.Height; y++)
{
for (var x = 0; x < image.Width; x++)
{
var p = image.GetPixel(x, y);
if (p.Alpha == 0) continue;
float a = p.Alpha / 255f;
byte r = (byte)(p.Red * a);
byte g = (byte)(p.Green * a);
byte b = (byte)(p.Blue * a);
canvas.SetPixel(x, y, new(r, g, b, p.Alpha));
}
}
return ((IRenderable)canvas).Render(options, maxWidth);
}
}
}

View File

@@ -0,0 +1,471 @@
using NLog;
using Spectre.Console;
using Spine;
using Spine.Exporters;
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
namespace SpineViewerCLI
{
public enum ExportFormat
{
Png = 0x0100,
Jpg = 0x0101,
Webp = 0x0102,
Frames = 0x0200,
Gif = 0x0300,
Webpa = 0x0301,
Apng = 0x0302,
Mp4 = 0x0303,
Webm = 0x0304,
Mkv = 0x0305,
Mov = 0x0306,
Custom = 0x0400,
}
public class ExportCommand : Command
{
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
private static readonly string _name = "export";
private static readonly string _desc = "Export single model";
#region >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
public Argument<FileInfo> ArgSkel { get; } = new("skel")
{
Description = "Path of skel file.",
};
public Option<ExportFormat> OptFormat { get; } = new("--format", "-f")
{
Description = "Export format.",
Required = true,
};
public Option<string> OptOutput { get; } = new("--output", "-o")
{
Description = "Output file or directory. Use a directory for frame sequence export.",
Required = true,
};
public Option<string[]> OptAnimations { get; } = new("--animations", "-a")
{
Description = "Animations to export. Supports multiple entries, placed in order on tracks starting from 0.",
Required = true,
Arity = ArgumentArity.OneOrMore,
AllowMultipleArgumentsPerToken = true,
};
public Option<FileInfo> OptAtlas { get; } = new("--atlas")
{
Description = "Path to the atlas file that matches the skel file.",
};
public Option<float> OptScale { get; } = new("--scale")
{
Description = "Scale factor of the model.",
DefaultValueFactory = _ => 1f,
};
public Option<bool> OptPma { get; } = new("--pma")
{
Description = "Specifies whether the texture uses PMA (premultiplied alpha) format.",
};
public Option<string[]> OptSkins { get; } = new("--skins")
{
Description = "Skins to export. Multiple skins can be specified.",
Arity = ArgumentArity.OneOrMore,
AllowMultipleArgumentsPerToken = true,
};
public Option<string[]> OptDisableSlots { get; } = new("--disable-slots")
{
Description = "Slots to disable during export. Multiple slots can be specified.",
Arity = ArgumentArity.OneOrMore,
AllowMultipleArgumentsPerToken = true,
};
public Option<float> OptWarmUp { get; } = new("--warm-up")
{
Description = "Warm-up duration of the animation, used to stabilize physics effects. A negative value will automatically warm up for the maximum duration among all animations.",
DefaultValueFactory = _ => 0f,
};
public Option<bool> OptNoProgress { get; } = new("--no-progress")
{
Description = "Do not display real-time progress.",
};
#endregion
#region >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
public Option<SFML.Graphics.Color> OptColor { get; } = new("--color")
{
Description = "Background color of content.",
//DefaultValueFactory = ...
CustomParser = Utils.ParseColor
};
public Option<uint> OptMargin { get; } = new("--margin")
{
Description = "Size of the margin (in pixels) around the content.",
DefaultValueFactory = _ => 0u,
};
public Option<uint> OptMaxResolution { get; } = new("--max-resolution")
{
Description = "Maximum width or height (in pixels) for exported images.",
DefaultValueFactory = _ => 2048u,
};
public Option<float> OptTime { get; } = new("--time")
{
Description = "Start time offset of the animation.",
DefaultValueFactory = _ => 0f,
};
public Option<float> OptDuration { get; } = new("--duration")
{
Description = "Export duration. Negative values indicate automatic duration calculation.",
DefaultValueFactory = _ => -1f,
};
public Option<uint> OptFps { get; } = new("--fps")
{
Description = "Frame rate for export.",
DefaultValueFactory = _ => 30u,
};
public Option<float> OptSpeed { get; } = new("--speed")
{
Description = "Speed factor for the exported animation.",
DefaultValueFactory = _ => 1f,
};
public Option<bool> OptDropLastFrame { get; } = new("--drop-last-frame")
{
Description = "Whether to drop the incomplete last frame.",
};
#endregion
#region >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
public Option<uint> OptQuality { get; } = new("--quality")
{
Description = "Image quality.",
DefaultValueFactory = _ => 80u,
};
public Option<bool> OptLoop { get; } = new("--loop")
{
Description = "Whether the animation should loop.",
};
public Option<bool> OptLossless { get; } = new("--lossless")
{
Description = "Whether to encode the WebP animation losslessly.",
};
public Option<FFmpegVideoExporter.ApngPredMethod> OptApngPredMethod { get; } = new("--apng-pred")
{
Description = "Prediction method used for APNG animations.",
DefaultValueFactory = _ => FFmpegVideoExporter.ApngPredMethod.Mixed,
};
public Option<uint> OptCrf { get; } = new("--crf")
{
Description = "CRF (Constant Rate Factor) value for encoding.",
DefaultValueFactory = _ => 23u,
};
public Option<FFmpegVideoExporter.MovProfile> OptMovProfile { get; } = new("--mov-profile")
{
Description = "Profile setting for MOV format export.",
DefaultValueFactory = _ => FFmpegVideoExporter.MovProfile.Yuv4444Extreme,
};
#endregion
#region >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
public Option<string> OptFFFormat { get; } = new("--ff-format")
{
Description = "format option of ffmpeg",
};
public Option<string> OptFFCodec { get; } = new("--ff-codec")
{
Description = "codec option of ffmpeg",
};
public Option<string> OptFFPixelFormat { get; } = new("--ff-pixfmt")
{
Description = "pixel format option of ffmpeg",
};
public Option<string> OptFFBitrate { get; } = new("--ff-bitrate")
{
Description = "bitrate option of ffmpeg",
};
public Option<string> OptFFFilter { get; } = new("--ff-filter")
{
Description = "filter option of ffmpeg",
};
public Option<string> OptFFArgs { get; } = new("--ff-args")
{
Description = "other arguments of ffmpeg",
};
#endregion
public ExportCommand() : base(_name, _desc)
{
OptColor.DefaultValueFactory = r =>
{
var defVal = SFML.Graphics.Color.Black;
try
{
switch (r.GetValue(OptFormat))
{
case ExportFormat.Png:
case ExportFormat.Webp:
case ExportFormat.Frames:
case ExportFormat.Gif:
case ExportFormat.Webpa:
case ExportFormat.Apng:
case ExportFormat.Webm:
defVal = SFML.Graphics.Color.Transparent;
break;
}
}
catch (InvalidOperationException) { } // 未提供 OptFormat 的时候 GetValue 会报错
return defVal;
};
OptScale.Validators.Add(r =>
{
if (r.Tokens.Count > 0 && float.TryParse(r.Tokens[0].Value, out var v) && v < 0)
r.AddError($"{OptScale.Name} must be non-negative.");
});
OptTime.Validators.Add(r =>
{
if (r.Tokens.Count > 0 && float.TryParse(r.Tokens[0].Value, out var v) && v < 0)
r.AddError($"{OptTime.Name} must be non-negative.");
});
OptSpeed.Validators.Add(r =>
{
if (r.Tokens.Count > 0 && float.TryParse(r.Tokens[0].Value, out var v) && v < 0)
r.AddError($"{OptSpeed.Name} must be non-negative.");
});
this.AddArgsAndOpts();
SetAction(ExportAction);
}
private void ExportAction(ParseResult result)
{
// 读取模型
using var spine = new SpineObject(result.GetValue(ArgSkel)!.FullName, result.GetValue(OptAtlas)?.FullName);
// 设置模型参数
spine.Skeleton.ScaleX = spine.Skeleton.ScaleY = result.GetValue(OptScale);
spine.UsePma = result.GetValue(OptPma);
// 设置要导出的动画
int trackIdx = 0;
foreach (var name in result.GetValue(OptAnimations))
{
if (!spine.Data.AnimationsByName.ContainsKey(name))
{
_logger.Warn("No animation named '{0}', skip it", name);
continue;
}
spine.AnimationState.SetAnimation(trackIdx, name, true);
trackIdx++;
}
// 设置需要启用的皮肤
foreach (var name in result.GetValue(OptSkins))
{
if (!spine.SetSkinStatus(name, true))
{
_logger.Warn("Failed to enable skin '{0}'", name);
}
}
// 设置需要屏蔽的插槽
foreach (var name in result.GetValue(OptDisableSlots))
{
if (!spine.SetSlotVisible(name, false))
{
_logger.Warn("Failed to disable slot '{0}'", name);
}
}
// TODO: 设置要启用的插槽
// 时间轴处理
var warmup = result.GetValue(OptWarmUp);
spine.Update(warmup < 0 ? spine.GetAnimationMaxDuration() : warmup);
spine.Update(result.GetValue(OptTime));
using var exporter = GetExporterFilledWithArgs(result, spine);
// 创建输出目录
string output = result.GetValue(OptOutput);
Directory.CreateDirectory(exporter is FrameSequenceExporter ? output : Path.GetDirectoryName(output));
// 挂载进度报告函数
if (exporter is VideoExporter ve && !result.GetValue(OptNoProgress))
{
AnsiConsole.Progress().Columns(
[
new TaskDescriptionColumn(),
new ProgressBarColumn(),
new PercentageColumn(),
new RemainingTimeColumn(),
new SpinnerColumn(),
]).Start(ctx =>
{
var task = ctx.AddTask($"Exporting '{spine.Name}'");
task.MaxValue = ve.GetFrameCount();
ve.ProgressReporter = (total, done, text) => task.Value = done;
ve.Export(output, spine);
});
}
else
{
exporter.Export(output, spine);
}
_logger.Info($"{spine.SkelPath} export completed");
}
private BaseExporter GetExporterFilledWithArgs(ParseResult result, SpineObject spine)
{
var formatType = (int)result.GetValue(OptFormat) >> 8;
// 根据模型获取自动分辨率和视区参数
var maxResolution = result.GetValue(OptMaxResolution);
var margin = result.GetValue(OptMargin);
var bounds = formatType == 0x01 ? spine.GetCurrentBounds() : spine.GetAnimationBounds(result.GetValue(OptFps));
var resolution = new SFML.System.Vector2u((uint)bounds.Size.X, (uint)bounds.Size.Y);
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);
}
var viewBounds = bounds.GetCanvasBounds(resolution, margin);
var duration = result.GetValue(OptDuration);
if (duration < 0) duration = spine.GetAnimationMaxDuration();
if (formatType == 0x01)
{
return new FrameExporter(resolution.X + margin * 2, resolution.Y + margin * 2)
{
Size = new(viewBounds.Width, -viewBounds.Height),
Center = viewBounds.Position + viewBounds.Size / 2,
Rotation = 0,
BackgroundColor = result.GetValue(OptColor),
Format = result.GetValue(OptFormat) switch
{
ExportFormat.Png => SkiaSharp.SKEncodedImageFormat.Png,
ExportFormat.Jpg => SkiaSharp.SKEncodedImageFormat.Jpeg,
ExportFormat.Webp => SkiaSharp.SKEncodedImageFormat.Webp,
var v => throw new InvalidOperationException($"{v}"),
},
Quality = (int)result.GetValue(OptQuality),
};
}
else if (formatType == 0x02)
{
return new FrameSequenceExporter(resolution.X + margin * 2, resolution.Y + margin * 2)
{
Size = new(viewBounds.Width, -viewBounds.Height),
Center = viewBounds.Position + viewBounds.Size / 2,
Rotation = 0,
BackgroundColor = result.GetValue(OptColor),
Fps = result.GetValue(OptFps),
Speed = result.GetValue(OptSpeed),
KeepLast = !result.GetValue(OptDropLastFrame),
Duration = duration,
};
}
else if (formatType == 0x03)
{
return new FFmpegVideoExporter(resolution.X + margin * 2, resolution.Y + margin * 2)
{
Size = new(viewBounds.Width, -viewBounds.Height),
Center = viewBounds.Position + viewBounds.Size / 2,
Rotation = 0,
BackgroundColor = result.GetValue(OptColor),
Fps = result.GetValue(OptFps),
Speed = result.GetValue(OptSpeed),
KeepLast = !result.GetValue(OptDropLastFrame),
Duration = duration,
Format = result.GetValue(OptFormat) switch
{
ExportFormat.Gif => FFmpegVideoExporter.VideoFormat.Gif,
ExportFormat.Webpa => FFmpegVideoExporter.VideoFormat.Webp,
ExportFormat.Apng => FFmpegVideoExporter.VideoFormat.Apng,
ExportFormat.Mp4 => FFmpegVideoExporter.VideoFormat.Mp4,
ExportFormat.Webm => FFmpegVideoExporter.VideoFormat.Webm,
ExportFormat.Mkv => FFmpegVideoExporter.VideoFormat.Mkv,
ExportFormat.Mov => FFmpegVideoExporter.VideoFormat.Mov,
var v => throw new InvalidOperationException($"{v}"),
},
Quality = (int)result.GetValue(OptQuality),
Loop = result.GetValue(OptLoop),
Lossless = result.GetValue(OptLossless),
PredMethod = result.GetValue(OptApngPredMethod),
Crf = (int)result.GetValue(OptCrf),
Profile = result.GetValue(OptMovProfile),
}
;
}
else if (formatType == 0x04)
{
return new CustomFFmpegExporter(resolution.X + margin * 2, resolution.Y + margin * 2)
{
Size = new(viewBounds.Width, -viewBounds.Height),
Center = viewBounds.Position + viewBounds.Size / 2,
Rotation = 0,
BackgroundColor = result.GetValue(OptColor),
Fps = result.GetValue(OptFps),
Speed = result.GetValue(OptSpeed),
KeepLast = !result.GetValue(OptDropLastFrame),
Duration = duration,
Format = result.GetValue(OptFFFormat),
Codec = result.GetValue(OptFFCodec),
PixelFormat = result.GetValue(OptFFPixelFormat),
Bitrate = result.GetValue(OptFFBitrate),
Filter = result.GetValue(OptFFFilter),
CustomArgs = result.GetValue(OptFFArgs),
};
}
else
{
throw new ArgumentOutOfRangeException($"Unknown format type {formatType}");
}
}
}
}

105
SpineViewerCLI/Extension.cs Normal file
View File

@@ -0,0 +1,105 @@
using SFML.Graphics;
using SFML.System;
using Spine;
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewerCLI
{
public static class Extension
{
/// <summary>
/// 获取一个对象副本, 继承所有状态
/// </summary>
public static SpineObject Copy(this SpineObject self, bool keepTrackTime = false)
{
var spineObject = new SpineObject(self, true);
// 拷贝轨道动画, 但是仅拷贝第一个条目
foreach (var tr in self.AnimationState.IterTracks().Where(t => t is not null))
{
var t = spineObject.AnimationState.SetAnimation(tr!.TrackIndex, tr.Animation, tr.Loop);
t.TimeScale = tr.TimeScale;
t.Alpha = tr.Alpha;
if (keepTrackTime)
t.TrackTime = tr.TrackTime;
}
// XXX(#105): 部分 3.4.02 版本模型在设置动画后出现附件残留, 因此强制进行一次 Setup
if (spineObject.Version == SpineVersion.V34)
{
spineObject.Skeleton.SetSlotsToSetupPose();
}
spineObject.Update(0);
return spineObject;
}
/// <summary>
/// 获取当前状态包围盒
/// </summary>
public static FloatRect GetCurrentBounds(this SpineObject self)
{
self.Skeleton.GetBounds(out var x, out var y, out var w, out var h);
return new(x, y, Math.Max(w, 1e-6f), Math.Max(h, 1e-6f));
}
/// <summary>
/// 计算所有轨道第一个条目的动画时长最大值
/// </summary>
/// <param name="self"></param>
/// <returns></returns>
public static float GetAnimationMaxDuration(this SpineObject self)
{
return self.AnimationState.IterTracks().Select(t => t?.Animation.Duration ?? 0).DefaultIfEmpty(0).Max();
}
/// <summary>
/// 合并另一个矩形
/// </summary>
public static FloatRect Union(this FloatRect self, FloatRect rect)
{
float left = Math.Min(self.Left, rect.Left);
float top = Math.Min(self.Top, rect.Top);
float right = Math.Max(self.Left + self.Width, rect.Left + rect.Width);
float bottom = Math.Max(self.Top + self.Height, rect.Top + rect.Height);
return new(left, top, right - left, bottom - top);
}
/// <summary>
/// 按给定的帧率获取所有轨道第一个条目动画全时长包围盒大小, 是一个耗时操作, 如果可能的话最好缓存结果
/// </summary>
public static FloatRect GetAnimationBounds(this SpineObject self, float fps = 30)
{
using var copy = self.Copy();
var bounds = copy.GetCurrentBounds();
var maxDuration = copy.GetAnimationMaxDuration();
for (float tick = 0, delta = 1 / fps; tick < maxDuration; tick += delta)
{
bounds = bounds.Union(copy.GetCurrentBounds());
copy.Update(delta);
}
return bounds;
}
/// <summary>
/// 自动添加所有能找到的类型是 <see cref="Argument"/> 或者 <see cref="Option"/> 的公开属性
/// </summary>
/// <param name="self"></param>
public static void AddArgsAndOpts(this Command self)
{
// 用反射查找自己所有的公开属性是 Argument 或者 Option 的
foreach (var prop in self.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
var value = prop.GetValue(self);
if (value is Argument arg) self.Add(arg);
else if (value is Option opt) self.Add(opt);
}
}
}
}

View File

@@ -0,0 +1,137 @@
using NLog;
using Spectre.Console;
using Spine;
using Spine.Exporters;
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewerCLI
{
public class PreviewCommand : Command
{
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
private static readonly string _name = "preview";
private static readonly string _desc = "Preview a model";
private static readonly int MaxResolution = 1024;
public Argument<FileInfo> ArgSkel { get; } = new("skel")
{
Description = "Path of skel file.",
};
public Option<FileInfo> OptAtlas { get; } = new("--atlas")
{
Description = "Path to the atlas file that matches the skel file.",
};
public Option<bool> OptPma { get; } = new("--pma")
{
Description = "Specifies whether the texture uses PMA (premultiplied alpha) format.",
};
public Option<string[]> OptSkins { get; } = new("--skins")
{
Description = "Skins to enable. Multiple skins can be specified.",
Arity = ArgumentArity.OneOrMore,
AllowMultipleArgumentsPerToken = true,
};
public Option<string[]> OptAnimations { get; } = new("--animations")
{
Description = "Animations to export. Supports multiple entries, placed in order on tracks starting from 0.",
Arity = ArgumentArity.OneOrMore,
AllowMultipleArgumentsPerToken = true,
};
public Option<float> OptTime { get; } = new("--time")
{
Description = "Start time offset of the animation.",
DefaultValueFactory = _ => 0f,
};
public Option<bool> OptUseChars { get; } = new("--use-chars")
{
Description = "Whether to use characters instead of colored spaces for pixels",
};
public PreviewCommand() : base(_name, _desc)
{
OptTime.Validators.Add(r =>
{
if (r.Tokens.Count > 0 && float.TryParse(r.Tokens[0].Value, out var v) && v < 0)
r.AddError($"{OptTime.Name} must be non-negative.");
});
this.AddArgsAndOpts();
SetAction(PreviewAction);
}
private void PreviewAction(ParseResult result)
{
// 读取模型
using var spine = new SpineObject(result.GetValue(ArgSkel)!.FullName, result.GetValue(OptAtlas)?.FullName);
spine.UsePma = result.GetValue(OptPma);
// 设置要导出的动画
int trackIdx = 0;
foreach (var name in result.GetValue(OptAnimations))
{
if (!spine.Data.AnimationsByName.ContainsKey(name))
{
_logger.Warn("No animation named '{0}', skip it", name);
continue;
}
spine.AnimationState.SetAnimation(trackIdx, name, true);
trackIdx++;
}
// 设置需要启用的皮肤
foreach (var name in result.GetValue(OptSkins))
{
if (!spine.SetSkinStatus(name, true))
{
_logger.Warn("Failed to enable skin '{0}'", name);
}
}
// 设置时间偏移量
spine.Update(result.GetValue(OptTime));
using var exporter = GetExporterFilledWithArgs(result, spine);
using var skImage = exporter.ExportMemoryImage(spine);
var img = new CanvasImageAscii(skImage) { UsePixelCharacters = result.GetValue(OptUseChars) };
AnsiConsole.Write(img);
}
private FrameExporter GetExporterFilledWithArgs(ParseResult result, SpineObject spine)
{
// 根据模型获取自动分辨率和视区参数
var bounds = spine.GetCurrentBounds();
var resolution = new SFML.System.Vector2u((uint)bounds.Size.X, (uint)bounds.Size.Y);
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);
}
var viewBounds = bounds.GetCanvasBounds(resolution);
return new FrameExporter(resolution)
{
Size = new(viewBounds.Width, -viewBounds.Height),
Center = viewBounds.Position + viewBounds.Size / 2,
Rotation = 0,
BackgroundColor = SFML.Graphics.Color.Transparent,
Format = SkiaSharp.SKEncodedImageFormat.Png,
Quality = 100,
};
}
}
}

View File

@@ -0,0 +1,121 @@
using NLog;
using Spine;
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewerCLI
{
public class QueryCommand : Command
{
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
private static readonly string _name = "query";
private static readonly string _desc = "Query information of single model";
private static readonly string HalfHeader = new('>', 15);
private static readonly char Separator = '\t';
public Argument<FileInfo> ArgSkel { get; } = new("skel")
{
Description = "Path of skel file.",
};
public Option<FileInfo> OptAtlas { get; } = new("--atlas")
{
Description = "Path to the atlas file that matches the skel file.",
};
public Option<bool> OptAll { get; } = new("--all")
{
Description = "Print all information",
};
public Option<bool> OptSkin { get; } = new("--skin")
{
Description = "Print skins",
};
public Option<bool> OptAnimation { get; } = new("--animation")
{
Description = "Print animations",
};
public Option<bool> OptSlot { get; } = new("--slot")
{
Description = "Print slots",
};
public QueryCommand() : base(_name, _desc)
{
this.AddArgsAndOpts();
SetAction(QueryAction);
}
private void QueryAction(ParseResult result)
{
// 读取模型
using var spine = new SpineObject(result.GetValue(ArgSkel)!.FullName, result.GetValue(OptAtlas)?.FullName);
var all = result.GetValue(OptAll);
if (all || result.GetValue(OptSkin))
{
SkinRecord[] data = spine.Data.SkinsByName.Keys.Select(v => new SkinRecord(v)).ToArray();
PrintData("Skins", SkinRecord.Headers, data);
}
if (all || result.GetValue(OptAnimation))
{
AnimationRecord[] data = spine.Data.Animations.Select(v => new AnimationRecord(v.Name, v.Duration)).ToArray();
PrintData("Animations", AnimationRecord.Headers, data);
}
if (all || result.GetValue(OptSlot))
{
SlotRecord[] data = spine.Data.SlotAttachments.Select(v => new SlotRecord(v.Key, v.Value.Keys.ToArray())).ToArray();
PrintData("Slots", SlotRecord.Headers, data);
}
}
private void PrintData(string dataName, string[] headers, RowRecord[] rows)
{
var header = $"{HalfHeader} {dataName} {HalfHeader}";
var footer = new string('<', header.Length);
Console.WriteLine(header);
Console.WriteLine(string.Join(Separator, headers));
foreach (var row in rows)
Console.WriteLine(string.Join(Separator, row.Values));
Console.WriteLine(footer);
}
}
public abstract record RowRecord
{
public abstract object[] Values { get; }
}
public record SkinRecord(string Name) : RowRecord
{
public static string[] Headers { get; } = [nameof(Name)];
public override object[] Values => [Name];
}
public record AnimationRecord(string Name, float Duration) : RowRecord
{
public static string[] Headers { get; } = [nameof(Name), nameof(Duration)];
public override object[] Values => [Name, Duration];
}
public record SlotRecord(string Name, string[] Attachments) : RowRecord
{
public static string[] Headers { get; } = [nameof(Name), nameof(Attachments)];
public override object[] Values => [Name, string.Join(';', Attachments)];
}
}

3
SpineViewerCLI/README.md Normal file
View File

@@ -0,0 +1,3 @@
# SpineViewerCLI
基于 [System.Command](https://www.nuget.org/packages/System.CommandLine) 的命令行工具.

View File

@@ -1,240 +1,100 @@
using System.Globalization;
using System.IO;
using SFML.Graphics;
using SFML.System;
using NLog;
using SkiaSharp;
using Spectre.Console;
using Spine;
using Spine.Exporters;
using System.CommandLine;
using System.Globalization;
using System.Runtime.InteropServices;
namespace SpineViewerCLI
{
public class CLI
public static class SpineViewerCLI
{
const string USAGE = @"
usage: SpineViewerCLI.exe [--skel PATH] [--atlas PATH] [--output PATH] [--animation STR] [--pma] [--fps INT] [--loop] [--crf INT] [--width INT] [--height INT] [--centerx INT] [--centery INT] [--zoom FLOAT] [--speed FLOAT] [--color HEX] [--quiet]
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
options:
--skel PATH Path to the .skel file
--atlas PATH Path to the .atlas file, default searches in the skel file directory
--output PATH Output file path
--animation STR Animation name
--pma Use premultiplied alpha, default false
--fps INT Frames per second, default 24
--loop Whether to loop the animation, default false
--crf INT Constant Rate Factor i.e. video quality, from 0 (lossless) to 51 (worst), default 23
--width INT Output width, default 512
--height INT Output height, default 512
--centerx INT Center X offset, default automatically finds bounds
--centery INT Center Y offset, default automatically finds bounds
--zoom FLOAT Zoom level, default 1.0
--speed FLOAT Speed of animation, default 1.0
--color HEX Background color as a hex RGBA color, default 000000ff (opaque black)
--quiet Removes console progress log, default false
";
public static void Main(string[] args)
public static Option<bool> OptQuiet { get; } = new("--quiet", "-q")
{
string? skelPath = null;
string? atlasPath = null;
string? output = null;
string? animation = null;
bool pma = false;
uint fps = 24;
bool loop = false;
int crf = 23;
uint? width = null;
uint? height = null;
int? centerx = null;
int? centery = null;
float zoom = 1;
float speed = 1;
Color backgroundColor = Color.Black;
bool quiet = false;
Description = "Suppress console logging (quiet mode).",
Recursive = true,
};
for (int i = 0; i < args.Length; i++)
public static int Main(string[] args)
{
InitializeFileLog();
var cmdRoot = new RootCommand("Root Command")
{
switch (args[i])
{
case "--help":
Console.Write(USAGE);
Environment.Exit(0);
break;
case "--skel":
skelPath = args[++i];
break;
case "--atlas":
atlasPath = args[++i];
break;
case "--output":
output = args[++i];
break;
case "--animation":
animation = args[++i];
break;
case "--pma":
pma = true;
break;
case "--fps":
fps = uint.Parse(args[++i]);
break;
case "--loop":
loop = true;
break;
case "--crf":
crf = int.Parse(args[++i]);
break;
case "--width":
width = uint.Parse(args[++i]);
break;
case "--height":
height = uint.Parse(args[++i]);
break;
case "--centerx":
centerx = int.Parse(args[++i]);
break;
case "--centery":
centery = int.Parse(args[++i]);
break;
case "--zoom":
zoom = float.Parse(args[++i]);
break;
case "--speed":
speed = float.Parse(args[++i]);
break;
case "--color":
backgroundColor = new Color(uint.Parse(args[++i], NumberStyles.HexNumber));
break;
case "--quiet":
quiet = true;
break;
default:
Console.Error.WriteLine($"Unknown argument: {args[i]}");
Environment.Exit(2);
break;
}
}
OptQuiet,
new QueryCommand(),
new PreviewCommand(),
new ExportCommand(),
};
if (string.IsNullOrEmpty(skelPath))
var result = cmdRoot.Parse(args);
if (!result.GetValue(OptQuiet))
InitializeConsoleLog();
try
{
Console.Error.WriteLine("Missing --skel");
Environment.Exit(2);
return result.Invoke();
}
if (string.IsNullOrEmpty(output))
catch (Exception ex)
{
Console.Error.WriteLine("Missing --output");
Environment.Exit(2);
_logger.Trace(ex.ToString());
_logger.Fatal("Failed to execute, {0}", ex.Message);
return -1;
}
if (!Enum.TryParse<FFmpegVideoExporter.VideoFormat>(Path.GetExtension(output).TrimStart('.'), true, out var videoFormat))
{
var validExtensions = string.Join(", ", Enum.GetNames(typeof(FFmpegVideoExporter.VideoFormat)));
Console.Error.WriteLine($"Invalid output extension. Supported formats are: {validExtensions}");
Environment.Exit(2);
}
var sp = new SpineObject(skelPath, atlasPath);
sp.UsePma = pma;
if (string.IsNullOrEmpty(animation))
{
var availableAnimations = string.Join(", ", sp.Data.Animations);
Console.Error.WriteLine($"Missing --animation. Available animations for {sp.Name}: {availableAnimations}");
Environment.Exit(2);
}
var trackEntry = sp.AnimationState.SetAnimation(0, animation, loop);
sp.Update(0);
FFmpegVideoExporter exporter;
if (width is uint w && height is uint h && centerx is int cx && centery is int cy)
{
exporter = new FFmpegVideoExporter(w, h)
{
Center = (cx, cy),
Size = (w / zoom, -h / zoom),
};
}
else
{
var bounds = GetFloatRectCanvasBounds(GetSpineObjectAnimationBounds(sp, fps), new(width ?? 512, height ?? 512));
exporter = new FFmpegVideoExporter(width ?? (uint)Math.Ceiling(bounds.Width), height ?? (uint)Math.Ceiling(bounds.Height))
{
Center = bounds.Position + bounds.Size / 2,
Size = (bounds.Width, -bounds.Height),
};
}
exporter.Duration = trackEntry.Animation.Duration;
exporter.Fps = fps;
exporter.Format = videoFormat;
exporter.Loop = loop;
exporter.Crf = crf;
exporter.Speed = speed;
exporter.BackgroundColor = backgroundColor;
if (!quiet)
exporter.ProgressReporter = (total, done, text) => Console.Write($"\r{text}");
using var cts = new CancellationTokenSource();
exporter.Export(output, cts.Token, sp);
if (!quiet)
Console.WriteLine();
Environment.Exit(0);
}
public static SpineObject CopySpineObject(SpineObject sp)
private static void InitializeFileLog()
{
var spineObject = new SpineObject(sp, true);
foreach (var tr in sp.AnimationState.IterTracks().Where(t => t is not null))
// XXX: δ֪ԭ<D6AA><D4AD> linux ƽ̨<C6BD><CCA8><EFBFBD>޷<EFBFBD><DEB7><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>־<EFBFBD>ļ<EFBFBD>
var config = new NLog.Config.LoggingConfiguration();
var fileTarget = new NLog.Targets.FileTarget("fileTarget")
{
var t = spineObject.AnimationState.SetAnimation(tr!.TrackIndex, tr.Animation, tr.Loop);
}
spineObject.Update(0);
return spineObject;
Encoding = System.Text.Encoding.UTF8,
Layout = "${date:format=yyyy-MM-dd HH\\:mm\\:ss} - ${level:uppercase=true} - ${processid} - ${callsite-filename:includeSourcePath=false}:${callsite-linenumber} - ${message}",
AutoFlush = true,
CreateDirs = true,
FileName = "${basedir}/logs/cli.log",
ArchiveFileName = "${basedir}/logs/cli.{#}.log",
ArchiveNumbering = NLog.Targets.ArchiveNumberingMode.Rolling,
ArchiveAboveSize = 1048576,
MaxArchiveFiles = 5,
ConcurrentWrites = true,
KeepFileOpen = false,
};
config.AddTarget(fileTarget);
config.AddRule(LogLevel.Trace, LogLevel.Fatal, fileTarget);
LogManager.Configuration = config;
}
static FloatRect GetSpineObjectBounds(SpineObject sp)
private static void InitializeConsoleLog()
{
sp.Skeleton.GetBounds(out var x, out var y, out var w, out var h);
return new(x, y, Math.Max(w, 1e-6f), Math.Max(h, 1e-6f));
}
static FloatRect FloatRectUnion(FloatRect a, FloatRect b)
{
float left = Math.Min(a.Left, b.Left);
float top = Math.Min(a.Top, b.Top);
float right = Math.Max(a.Left + a.Width, b.Left + b.Width);
float bottom = Math.Max(a.Top + a.Height, b.Top + b.Height);
return new FloatRect(left, top, right - left, bottom - top);
}
static FloatRect GetSpineObjectAnimationBounds(SpineObject sp, float fps = 10)
{
sp = CopySpineObject(sp);
var bounds = GetSpineObjectBounds(sp);
var maxDuration = sp.AnimationState.IterTracks().Select(t => t?.Animation.Duration ?? 0).DefaultIfEmpty(0).Max();
sp.Update(0);
for (float tick = 0, delta = 1 / fps; tick < maxDuration; tick += delta)
var config = new NLog.Config.LoggingConfiguration();
var consoleTarget = new NLog.Targets.ColoredConsoleTarget("consoleTarget")
{
bounds = FloatRectUnion(bounds, GetSpineObjectBounds(sp));
sp.Update(delta);
}
return bounds;
}
static FloatRect GetFloatRectCanvasBounds(FloatRect rect, Vector2u resolution)
{
float sizeW = rect.Width;
float sizeH = rect.Height;
float innerW = resolution.X;
float innerH = resolution.Y;
var scale = Math.Max(Math.Abs(sizeW / innerW), Math.Abs(sizeH / innerH));
var scaleW = scale * Math.Sign(sizeW);
var scaleH = scale * Math.Sign(sizeH);
Encoding = System.Text.Encoding.UTF8,
Layout = "[${level:format=OneLetter}]${date:format=yyyy-MM-dd HH\\:mm\\:ss} - ${message}",
AutoFlush = true,
DetectConsoleAvailable = true,
StdErr = true,
DetectOutputRedirected = true,
};
innerW *= scaleW;
innerH *= scaleH;
consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Trace", NLog.Targets.ConsoleOutputColor.DarkGray, NLog.Targets.ConsoleOutputColor.NoChange));
consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Debug", NLog.Targets.ConsoleOutputColor.DarkGray, NLog.Targets.ConsoleOutputColor.NoChange));
consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Info", NLog.Targets.ConsoleOutputColor.DarkGray, NLog.Targets.ConsoleOutputColor.NoChange));
consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Warn", NLog.Targets.ConsoleOutputColor.DarkYellow, NLog.Targets.ConsoleOutputColor.NoChange));
consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Error", NLog.Targets.ConsoleOutputColor.Red, NLog.Targets.ConsoleOutputColor.NoChange));
consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Fatal", NLog.Targets.ConsoleOutputColor.White, NLog.Targets.ConsoleOutputColor.DarkRed));
var x = rect.Left - (innerW - sizeW) / 2;
var y = rect.Top - (innerH - sizeH) / 2;
var w = resolution.X * scaleW;
var h = resolution.Y * scaleH;
return new(x, y, w, h);
config.AddTarget(consoleTarget);
config.AddRule(LogLevel.Trace, LogLevel.Fatal, consoleTarget);
LogManager.Configuration = config;
}
}
}
}

View File

@@ -1,13 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<TargetFramework>net8.0-windows</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<TargetFramework>net8.0</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>0.0.1</Version>
<Version>0.16.10</Version>
<OutputType>Exe</OutputType>
</PropertyGroup>
@@ -16,7 +17,12 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SFMLRenderer\SFMLRenderer.csproj" />
<PackageReference Include="NLog" Version="5.4.0" />
<PackageReference Include="Spectre.Console" Version="0.52.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Spine\Spine.csproj" />
</ItemGroup>

67
SpineViewerCLI/Utils.cs Normal file
View File

@@ -0,0 +1,67 @@
using SFML.Graphics;
using System;
using System.Collections.Generic;
using System.CommandLine.Parsing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewerCLI
{
public static class Utils
{
public static Color ParseColor(ArgumentResult result)
{
var token = result.Tokens.Count > 0 ? result.Tokens[0].Value : null;
if (string.IsNullOrWhiteSpace(token))
return Color.Black;
try
{
// 去掉开头的 #
var hex = token.Trim().TrimStart('#');
// 支持格式: RGB / ARGB / RRGGBB / AARRGGBB
if (hex.Length == 3)
{
// #RGB → #RRGGBB
var r = hex[0];
var g = hex[1];
var b = hex[2];
hex = $"{r}{r}{g}{g}{b}{b}";
hex = "FF" + hex; // 加上不透明 alpha
}
else if (hex.Length == 4)
{
// #ARGB → #AARRGGBB
var a = hex[0];
var r = hex[1];
var g = hex[2];
var b = hex[3];
hex = $"{a}{a}{r}{r}{g}{g}{b}{b}";
}
else if (hex.Length == 6)
{
// #RRGGBB → #AARRGGBB
hex = "FF" + hex;
}
else if (hex.Length != 8)
{
result.AddError("Invalid color format. Use #RGB, #ARGB, #RRGGBB, or #AARRGGBB.");
return Color.Black;
}
var aVal = Convert.ToByte(hex[..2], 16);
var rVal = Convert.ToByte(hex.Substring(2, 2), 16);
var gVal = Convert.ToByte(hex.Substring(4, 2), 16);
var bVal = Convert.ToByte(hex.Substring(6, 2), 16);
return new(rVal, gVal, bVal, aVal);
}
catch
{
result.AddError("Invalid color format.");
return Color.Black;
}
}
}
}