Compare commits

..

42 Commits

Author SHA1 Message Date
ww-rm
7bc82ab318 Merge pull request #75 from ww-rm/dev/wpf
fix bug
2025-07-26 23:06:01 +08:00
ww-rm
3eb9b1d008 fix bug 2025-07-26 23:05:23 +08:00
ww-rm
eca59dc67b Merge pull request #74 from ww-rm/dev/wpf
v0.15.7
2025-07-26 23:01:21 +08:00
ww-rm
93b806dccd 更新至v0.15.7 2025-07-26 22:59:32 +08:00
ww-rm
89c31d7c77 add contributing 2025-07-26 22:58:27 +08:00
ww-rm
0a5432bb30 增加cli项目的生成 2025-07-26 22:32:15 +08:00
ww-rm
2eded25c03 add cli project 2025-07-26 22:28:37 +08:00
ww-rm
fa00f0064e remove warning 2025-07-26 22:28:25 +08:00
ww-rm
ddd3e94698 fix wrong text 2025-07-26 22:28:15 +08:00
ww-rm
d7ee88f7f6 Merge pull request #72 from ashleney/dev/wpf
Add CLI
2025-07-26 08:51:02 +08:00
ww-rm
1d7a402749 Update SpineViewerCLI.csproj 2025-07-26 08:49:34 +08:00
ashlen
86bcb079b0 Move to its own project 2025-07-25 19:01:23 +02:00
ashlen
390416df06 Add CLI 2025-07-25 17:19:16 +02:00
ww-rm
1344b34d08 修复时长参数0值判断问题 2025-07-25 22:09:24 +08:00
ww-rm
497103bdb6 Merge pull request #69 from ww-rm/dev/wpf 2025-07-25 14:00:15 +08:00
ww-rm
04953d13b6 update readme 2025-07-25 13:59:37 +08:00
ww-rm
b272d9802e 更新至v0.15.6 2025-07-25 13:55:31 +08:00
ww-rm
bd5a537058 update changelog 2025-07-25 13:55:24 +08:00
ww-rm
64a3caf938 修改默认导出背景颜色为不透明黑色 2025-07-25 13:53:55 +08:00
ww-rm
ca34494483 修复导出单个模式的时长错误 2025-07-25 13:50:36 +08:00
ww-rm
e717eab6df Merge pull request #68 from ww-rm/dev/wpf 2025-07-24 21:36:56 +08:00
ww-rm
068734549c 更新至v0.15.5 2025-07-24 21:35:02 +08:00
ww-rm
3d1fa38eb3 update changelog 2025-07-24 21:34:54 +08:00
ww-rm
bff3b39371 增加导出速度设置 2025-07-24 21:31:05 +08:00
ww-rm
a44161053b 修复yuv420p像素格式分辨率必须被2整除的问题 2025-07-24 21:27:41 +08:00
ww-rm
4b64ec74c2 增加预览画面播放速度参数 2025-07-24 20:38:55 +08:00
ww-rm
1f56e2f03c 修改mp4导出像素格式避免兼容性问题 2025-07-24 18:18:11 +08:00
ww-rm
311b09cc63 修复自定义导出问题 2025-07-24 17:58:55 +08:00
ww-rm
cd7f841e38 修复提示文本错误 2025-07-24 17:55:10 +08:00
ww-rm
df798b481d Merge pull request #65 from ww-rm/dev/wpf
Dev/wpf
2025-07-11 23:32:05 +08:00
ww-rm
578a9ad3f3 更新至v0.15.4 2025-07-11 23:31:10 +08:00
ww-rm
b765b5f7ea update changelog 2025-07-11 23:31:05 +08:00
ww-rm
f1c013bd82 增加webp格式无损参数 2025-07-11 23:28:36 +08:00
ww-rm
65c1012205 更改提示文本 2025-07-11 16:56:01 +08:00
ww-rm
f1cd9e25e5 修复使用FFmpeg导出时的卡死问题 2025-07-11 16:52:50 +08:00
ww-rm
b2861ffb93 Merge pull request #62 from ww-rm/dev/wpf
Dev/wpf
2025-06-29 19:51:55 +09:00
ww-rm
b01d112d63 Update CHANGELOG.md 2025-06-29 19:51:20 +09:00
ww-rm
58b13d00c1 Update Spine.csproj 2025-06-29 19:49:58 +09:00
ww-rm
0f5539ad41 Update SpineViewer.csproj 2025-06-29 19:49:29 +09:00
ww-rm
6d18ce882c Update SpineViewer.csproj 2025-06-29 19:46:04 +09:00
ww-rm
e1ea95c195 Merge pull request #61 from xiantuan/dev/wpf
Update SpineObject.cs add .skel.bytes Support
2025-06-29 11:47:51 +09:00
饭团
48ee61d1c6 Update SpineObject.cs add .skel.bytes Support
add .skel.bytes Support #58
2025-06-28 20:40:56 +08:00
31 changed files with 571 additions and 98 deletions

View File

@@ -13,6 +13,7 @@ jobs:
runs-on: windows-latest
env:
PROJECT_NAME: SpineViewer
PROJ_CLI_NAME: SpineViewerCLI
steps:
- name: Checkout code
@@ -54,11 +55,13 @@ jobs:
shell: pwsh
run: |
dotnet publish "$env:PROJECT_NAME\$env:PROJECT_NAME.csproj" -c Release -r win-x64 --sc false -o "publish\$env:PROJECT_NAME-$env:VERSION"
dotnet publish "$env:PROJ_CLI_NAME\$env:PROJ_CLI_NAME.csproj" -c Release -r win-x64 --sc false -o "publish\$env:PROJECT_NAME-$env:VERSION"
- name: Publish SelfContained version
shell: pwsh
run: |
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
shell: pwsh

View File

@@ -1,5 +1,27 @@
# CHANGELOG
## v0.15.6
- 修复导出单个的时长错误
- 修改默认导出背景色为不透明黑色
## v0.15.5
- 修复自定义导出时的画面错误
- 设置 mp4 像素格式为 yuv420p 避免 windows 默认播放器无法打开
- 增加预览画面和导出时的速度参数设置
- 修复一些提示文本错误
- 导出时自动将分辨率向下调整为 2 的倍数, 避免 yuv420p 格式出错
## v0.15.4
- 修复导出时可能的卡死问题
- 增加 webp 格式无损压缩参数
## v0.15.3
- 增加 skel.bytes 后缀识别
## v0.15.2
- 修复首选项文件读取为空时的提示信息

41
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,41 @@
# CONTRIBUTING
## 仓库分支
仓库目前包含 4 个分支:
- `main`: 默认分支, 也是项目最新版的发布用分支
- `dev/wpf`: WPF 版本开发分支
- `release/wf`: Winforms 旧版本发布分支 (已弃用, 仅进行 bug 修复)
- `dev/wf`: Winforms 旧版本开发分支 (已弃用, 仅进行 bug 修复)
仓库的每个发布分支都有对应的开发分支 `dev/*`, **在进行贡献和推送时请在开发分支上进行**, 待开发分支上审核完毕进行必要的确认 (例如版本号的更新) 后, 再从开发分支向对应的发布分支发起 pr, 合并后将会通过 Actions 进行自动生成和发布.
## 仓库结构
仓库目前包含两个可执行文件项目, 分别是:
- `SpineViewer.csproj`
- `SpineViewerCLI.csproj`
前者为仓库主要项目, 提供一个预览操作 Spine 模型文件的 UI 界面, 后者基于社区贡献进行开发, 提供一些便捷的 CLI 功能, 从而可以对模型文件进行一些批量操作.
除此之外其余项目均为一些基础功能库, 为以上两个项目提供必要的功能支持. 原则上 UI 项目和 CLI 项目二者独立互不引用, 仅引用相同的基础功能库, 以保证整个仓库的层次结构清晰便于维护.
## 如何贡献
对于一些小改动, 例如:
- 某些文件内的 bug 修复 (例如一些逻辑上的错误)
- 已有功能的扩展性增强 (例如在已有代码逻辑结构上扩充某些功能字段)
- 其他可能的对**已有功能**的修复改进
可以直接 fork 修改后向开发分支发起 pr, 经 review 无问题后可直接合并.
对于较大的改动, 例如:
- 新增某些代码文件 (例如需要添加一些全新的类)
- 添加一些全新的逻辑或者功能代码 (例如在自行车上加装发动机)
- 其他可能影响项目代码逻辑结构的改动
这些改动请先提 Issue, 进行必要性讨论, 以及确认新功能的引入方式, 请不要直接将这些可能的破坏性改动发起 pr.

View File

@@ -7,7 +7,7 @@
<TargetFramework>net8.0-windows</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>0.15.2</Version>
<Version>0.15.4</Version>
<UseWPF>true</UseWPF>
</PropertyGroup>

View File

@@ -25,7 +25,7 @@
- 支持自动分辨率批量导出
- 支持 FFmpeg 自定义导出
- 支持程序参数保存
- ...
- ......
### Spine 版本支持

View File

@@ -7,7 +7,7 @@
<TargetFramework>net8.0-windows</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>0.15.2</Version>
<Version>0.15.4</Version>
<UseWPF>true</UseWPF>
</PropertyGroup>

View File

@@ -31,6 +31,9 @@ namespace Spine.Exporters
/// <param name="height">画布高像素值</param>
public BaseExporter(uint width , uint height)
{
// XXX: 强制变成 2 的倍数, 防止像是 yuv420p 这种像素格式报错
width = width >> 1 << 1;
height = height >> 1 << 1;
if (width <= 0 || height <= 0)
throw new ArgumentException($"Invalid resolution: {width}, {height}");
_renderTexture = new(width, height);
@@ -42,6 +45,9 @@ namespace Spine.Exporters
/// </summary>
public BaseExporter(Vector2u resolution)
{
// XXX: 强制变成 2 的倍数, 防止像是 yuv420p 这种像素格式报错
resolution.X = resolution.X >> 1 << 1;
resolution.Y = resolution.Y >> 1 << 1;
if (resolution.X <= 0 || resolution.Y <= 0)
throw new ArgumentException($"Invalid resolution: {resolution}");
_renderTexture = new(resolution.X, resolution.Y);
@@ -76,12 +82,12 @@ namespace Spine.Exporters
_backgroundColorPma = bcPma;
}
}
protected Color _backgroundColor = Color.Transparent;
protected Color _backgroundColor = Color.Black;
/// <summary>
/// 预乘后的背景颜色
/// </summary>
protected Color _backgroundColorPma = Color.Transparent;
protected Color _backgroundColorPma = Color.Black;
/// <summary>
/// 画面分辨率
@@ -92,6 +98,9 @@ namespace Spine.Exporters
get => _renderTexture.Size;
set
{
// XXX: 强制变成 2 的倍数, 防止像是 yuv420p 这种像素格式报错
value.X = value.X >> 1 << 1;
value.Y = value.Y >> 1 << 1;
if (value.X <= 0 || value.Y <= 0)
{
_logger.Warn("Omit invalid exporter resolution: {0}", value);

View File

@@ -1,5 +1,6 @@
using FFMpegCore;
using FFMpegCore.Pipes;
using SFML.Graphics;
using SFML.System;
using System;
using System.Collections.Generic;
@@ -63,6 +64,22 @@ namespace Spine.Exporters
else options.WithCustomArgument("-vf unpremultiply=inplace=1");
}
/// <summary>
/// 获取的一帧, 结果是预乘的
/// </summary>
protected override SFMLImageVideoFrame GetFrame(SpineObject[] spines)
{
// BUG: 也许和 SFML 多线程或者 FFmpeg 调用有关, 当渲染线程也在运行的时候此处并行渲染会导致和 SFML 有关的内容都卡死
// 不知道为什么用 FFmpeg 必须临时创建 RenderTexture, 否则无法正常渲染, 会导致画面帧丢失
using var tex = new RenderTexture(_renderTexture.Size.X, _renderTexture.Size.Y);
using var view = _renderTexture.GetView();
tex.SetView(view);
tex.Clear(_backgroundColorPma);
foreach (var sp in spines.Reverse()) tex.Draw(sp);
tex.Display();
return new(tex.Texture.CopyToImage());
}
public override void Export(string output, CancellationToken ct, params SpineObject[] spines)
{
var videoFramesSource = new RawVideoPipeSource(GetFrames(spines, output, ct)) { FrameRate = _fps };

View File

@@ -51,6 +51,12 @@ namespace Spine.Exporters
public int Quality { get => _quality; set => _quality = Math.Clamp(value, 0, 100); }
private int _quality = 75;
/// <summary>
/// 无损压缩 (Webp)
/// </summary>
public bool Lossless { get => _lossless; set => _lossless = value; }
private bool _lossless = false;
/// <summary>
/// CRF
/// </summary>
@@ -62,7 +68,8 @@ namespace Spine.Exporters
/// </summary>
protected override SFMLImageVideoFrame GetFrame(SpineObject[] spines)
{
// XXX: 不知道为什么用 FFmpeg 必须临时创建 RenderTexture, 否则无法正常渲染
// BUG: 也许和 SFML 多线程或者 FFmpeg 调用有关, 当渲染线程也在运行的时候此处并行渲染会导致和 SFML 有关的内容都卡死
// 不知道为什么用 FFmpeg 必须临时创建 RenderTexture, 否则无法正常渲染, 会导致画面帧丢失
using var tex = new RenderTexture(_renderTexture.Size.X, _renderTexture.Size.Y);
using var view = _renderTexture.GetView();
tex.SetView(view);
@@ -112,15 +119,17 @@ namespace Spine.Exporters
private void SetWebpOptions(FFMpegArgumentOptions options)
{
var customArgs = $"-vf unpremultiply=inplace=1 -quality {_quality} -loop {(_loop ? 0 : 1)}";
var customArgs = $"-vf unpremultiply=inplace=1 -quality {_quality} -loop {(_loop ? 0 : 1)} -lossless {(_lossless ? 1 : 0)}";
options.ForceFormat("webp").WithVideoCodec("libwebp_anim").ForcePixelFormat("yuva420p")
.WithCustomArgument(customArgs);
}
private void SetMp4Options(FFMpegArgumentOptions options)
{
// XXX: windows 默认播放器在播放 MP4 格式时对于 libx264 编码器只支持 yuv420p 的像素格式
// 但是如果是 libx265 则没有该限制
var customArgs = "-vf unpremultiply=inplace=1";
options.ForceFormat("mp4").WithVideoCodec("libx264").ForcePixelFormat("yuv444p")
options.ForceFormat("mp4").WithVideoCodec("libx264").ForcePixelFormat("yuv420p")
.WithFastStart()
.WithConstantRateFactor(_crf)
.WithCustomArgument(customArgs);

View File

@@ -55,6 +55,21 @@ namespace Spine.Exporters
}
protected float _fps = 24;
public float Speed
{
get => _speed;
set
{
if (_speed <= 0)
{
_logger.Warn("Omit invalid speed: {0}", value);
return;
}
_speed = value;
}
}
protected float _speed = 1f;
/// <summary>
/// 是否保留最后一帧
/// </summary>
@@ -92,7 +107,7 @@ namespace Spine.Exporters
// 导出完整帧
for (int i = 0; i < total; i++)
{
foreach (var spine in spines) spine.Update(delta);
foreach (var spine in spines) spine.Update(delta * _speed);
yield return GetFrame(spines);
}
@@ -100,7 +115,7 @@ namespace Spine.Exporters
if (hasFinal)
{
// XXX: 此处还是按照完整的一帧时长进行更新, 也许可以只更新准确的最后一帧时长
foreach (var spine in spines) spine.Update(delta);
foreach (var spine in spines) spine.Update(delta * _speed);
yield return GetFrame(spines);
}
}

View File

@@ -7,7 +7,7 @@
<TargetFramework>net8.0-windows</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>0.15.2</Version>
<Version>0.15.6</Version>
</PropertyGroup>
<PropertyGroup>

View File

@@ -20,6 +20,7 @@ namespace Spine
public static readonly FrozenDictionary<string, string> PossibleSuffixMapping = new Dictionary<string, string>()
{
[".skel"] = ".atlas",
[".skel.bytes"] = ".atlas.txt",
[".json"] = ".atlas",
}.ToFrozenDictionary();

View File

@@ -28,6 +28,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionItems", "SolutionIt
.editorconfig = .editorconfig
.gitignore = .gitignore
CHANGELOG.md = CHANGELOG.md
CONTRIBUTING.md = CONTRIBUTING.md
README.en.md = README.en.md
README.md = README.md
EndProjectSection
@@ -36,6 +37,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SFMLRenderer", "SFMLRendere
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLog.Windows.Wpf", "NLog.Windows.Wpf\NLog.Windows.Wpf.csproj", "{8EAB9780-9DBA-A755-6C73-0CE5AC5CE557}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SpineViewerCLI", "SpineViewerCLI\SpineViewerCLI.csproj", "{6BC146FC-F81E-65DA-EDDF-5734DBCCB628}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
@@ -86,6 +89,10 @@ Global
{8EAB9780-9DBA-A755-6C73-0CE5AC5CE557}.Debug|x64.Build.0 = Debug|x64
{8EAB9780-9DBA-A755-6C73-0CE5AC5CE557}.Release|x64.ActiveCfg = Release|x64
{8EAB9780-9DBA-A755-6C73-0CE5AC5CE557}.Release|x64.Build.0 = Release|x64
{6BC146FC-F81E-65DA-EDDF-5734DBCCB628}.Debug|x64.ActiveCfg = Debug|x64
{6BC146FC-F81E-65DA-EDDF-5734DBCCB628}.Debug|x64.Build.0 = Debug|x64
{6BC146FC-F81E-65DA-EDDF-5734DBCCB628}.Release|x64.ActiveCfg = Release|x64
{6BC146FC-F81E-65DA-EDDF-5734DBCCB628}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@@ -37,6 +37,8 @@ namespace SpineViewer.Models
public uint MaxFps { get; set; } = 30;
public float Speed { get; set; } = 1f;
public bool ShowAxis { get; set; } = true;
public Color BackgroundColor { get; set; }

View File

@@ -104,6 +104,7 @@
<s:String x:Key="Str_Zoom">Zoom</s:String>
<s:String x:Key="Str_Rotation">Rotation (Degrees)</s:String>
<s:String x:Key="Str_MaxFps">Max FPS</s:String>
<s:String x:Key="Str_PlaySpeed">Playback Speed</s:String>
<s:String x:Key="Str_RenderSelectedOnly">Render Selected Only</s:String>
<s:String x:Key="Str_ShowAxis">Show Axis</s:String>
<s:String x:Key="Str_BackgroundColor">Background Color</s:String>
@@ -166,6 +167,8 @@
<s:String x:Key="Str_ExportDurationTooltip">Export duration; if less than 0, the maximum duration of all animations in all models will be used during export.</s:String>
<s:String x:Key="Str_Fps">FPS</s:String>
<s:String x:Key="Str_ExportSpeed">Export Speed</s:String>
<s:String x:Key="Str_ExportSpeedTooltip">Export speed factor; only affects the animation speed of the model, not the export duration or frame rate.</s:String>
<s:String x:Key="Str_KeepLastFrame">Keep Last Frame</s:String>
<s:String x:Key="Str_KeepLastFrameTooltip">When keeping the last frame, animation is smoother but frame count may be one higher</s:String>
@@ -174,6 +177,8 @@
<s:String x:Key="Str_LoopPlayTooltip">Loop animation; only effective for GIF/WebP formats</s:String>
<s:String x:Key="Str_QualityParameter">Quality Parameter</s:String>
<s:String x:Key="Str_QualityParameterTooltip">Range 0100; higher is better; only for WebP format</s:String>
<s:String x:Key="Str_LosslessParam">Lossless Compression</s:String>
<s:String x:Key="Str_LosslessParamTooltip">Lossless compression. Ignores the quality parameter and only applies to WebP format.</s:String>
<s:String x:Key="Str_CrfParameter">CRF Parameter</s:String>
<s:String x:Key="Str_CrfParameterTooltip">Range 063; lower is higher quality; only for MP4/WebM/MKV formats</s:String>
@@ -182,7 +187,7 @@
<s:String x:Key="Str_FFmpegCodec">Codec</s:String>
<s:String x:Key="Str_FFmpegCodecTooltip">FFmpeg codec (equivalent to "-c:v"), e.g. "libx264", "libx265"</s:String>
<s:String x:Key="Str_FFmpegPixelFormat">Pixel Format</s:String>
<s:String x:Key="Str_FFmpegPixelFormatTooltip">FFmpeg pixel format (equivalent to "-pix_fmt"), e.g. "yuv420", "yuv444"</s:String>
<s:String x:Key="Str_FFmpegPixelFormatTooltip">FFmpeg pixel format (equivalent to "-pix_fmt"), e.g. "yuv420p", "yuv444p"</s:String>
<s:String x:Key="Str_FFmpegBitrate">Bitrate</s:String>
<s:String x:Key="Str_FFmpegBitrateTooltip">FFmpeg bitrate (equivalent to "-b:v"), e.g. "6K", "2M"</s:String>
<s:String x:Key="Str_FFmpegFilter">Filter</s:String>

View File

@@ -104,6 +104,7 @@
<s:String x:Key="Str_Zoom">ズーム</s:String>
<s:String x:Key="Str_Rotation">回転(度)</s:String>
<s:String x:Key="Str_MaxFps">最大FPS</s:String>
<s:String x:Key="Str_PlaySpeed">再生速度</s:String>
<s:String x:Key="Str_RenderSelectedOnly">選択のみレンダリング</s:String>
<s:String x:Key="Str_ShowAxis">座標軸を表示</s:String>
<s:String x:Key="Str_BackgroundColor">背景色</s:String>
@@ -166,6 +167,8 @@
<s:String x:Key="Str_ExportDurationTooltip">エクスポート時間。0 未満の場合、エクスポート時にすべてのモデルのすべてのアニメーションの最大時間が使用されます。</s:String>
<s:String x:Key="Str_Fps">FPS</s:String>
<s:String x:Key="Str_ExportSpeed">エクスポート速度</s:String>
<s:String x:Key="Str_ExportSpeedTooltip">エクスポート速度係数。モデルの動作速度のみに影響し、エクスポート時間やフレームレートなどには影響しません。</s:String>
<s:String x:Key="Str_KeepLastFrame">最後のフレームを保持</s:String>
<s:String x:Key="Str_KeepLastFrameTooltip">最後のフレームを保持すると、アニメーションはより連続して見えますが、フレーム数が予想より1フレーム多くなる可能性があります</s:String>
@@ -174,6 +177,8 @@
<s:String x:Key="Str_LoopPlayTooltip">アニメーションをループ再生するか。Gif/Webp形式のみ有効です</s:String>
<s:String x:Key="Str_QualityParameter">品質パラメーター</s:String>
<s:String x:Key="Str_QualityParameterTooltip">品質パラメーター。値の範囲は0-100。数値が大きいほど品質が高くなります。Webp形式のみ有効です</s:String>
<s:String x:Key="Str_LosslessParam">可逆圧縮</s:String>
<s:String x:Key="Str_LosslessParamTooltip">可逆圧縮を行います。品質パラメータは無視され、WebP形式にのみ適用されます。</s:String>
<s:String x:Key="Str_CrfParameter">CRFパラメーター</s:String>
<s:String x:Key="Str_CrfParameterTooltip">CRFパラメーター。値の範囲は0-63。数値が小さいほど品質が高くなります。Mp4/Webm/Mkv形式のみ有効です</s:String>
@@ -182,7 +187,7 @@
<s:String x:Key="Str_FFmpegCodec">コーデック</s:String>
<s:String x:Key="Str_FFmpegCodecTooltip">FFmpegコーデック。パラメーター“-c:v”に相当します。例: “libx264”、“libx265”</s:String>
<s:String x:Key="Str_FFmpegPixelFormat">ピクセルフォーマット</s:String>
<s:String x:Key="Str_FFmpegPixelFormatTooltip">FFmpegピクセルフォーマット。パラメーター“-pix_fmt”に相当します。例: “yuv420”、“yuv444”</s:String>
<s:String x:Key="Str_FFmpegPixelFormatTooltip">FFmpegピクセルフォーマット。パラメーター“-pix_fmt”に相当します。例: “yuv420p”、“yuv444p”</s:String>
<s:String x:Key="Str_FFmpegBitrate">ビットレート</s:String>
<s:String x:Key="Str_FFmpegBitrateTooltip">FFmpegビットレート。パラメーター“-b:v”に相当します。例: “6K”、“2M”</s:String>
<s:String x:Key="Str_FFmpegFilter">フィルター</s:String>

View File

@@ -104,6 +104,7 @@
<s:String x:Key="Str_Zoom">缩放</s:String>
<s:String x:Key="Str_Rotation">旋转(角度)</s:String>
<s:String x:Key="Str_MaxFps">最大帧率</s:String>
<s:String x:Key="Str_PlaySpeed">播放速度</s:String>
<s:String x:Key="Str_RenderSelectedOnly">仅渲染选中</s:String>
<s:String x:Key="Str_ShowAxis">显示坐标轴</s:String>
<s:String x:Key="Str_BackgroundColor">背景颜色</s:String>
@@ -166,6 +167,8 @@
<s:String x:Key="Str_ExportDurationTooltip">导出时长,如果小于 0则在导出时使用所有模型所有动画的最大时长</s:String>
<s:String x:Key="Str_Fps">帧率</s:String>
<s:String x:Key="Str_ExportSpeed">导出速度</s:String>
<s:String x:Key="Str_ExportSpeedTooltip">导出速度因子, 仅影响模型的动作速度, 不影响导出时长和帧率等参数</s:String>
<s:String x:Key="Str_KeepLastFrame">保留最后一帧</s:String>
<s:String x:Key="Str_KeepLastFrameTooltip">当设置保留最后一帧时,动图会更为连贯,但是帧数可能比预期帧数多 1</s:String>
@@ -173,7 +176,9 @@
<s:String x:Key="Str_LoopPlay">循环播放</s:String>
<s:String x:Key="Str_LoopPlayTooltip">动图是否循环播放,仅对 Gif/Webp 格式生效</s:String>
<s:String x:Key="Str_QualityParameter">质量参数</s:String>
<s:String x:Key="Str_QualityParameterTooltip">质量参数,取值范围 0-100越高质量越好 仅对 Webp 格式生效</s:String>
<s:String x:Key="Str_QualityParameterTooltip">质量参数,取值范围 0-100越高质量越好, 仅对 Webp 格式生效</s:String>
<s:String x:Key="Str_LosslessParam">无损压缩</s:String>
<s:String x:Key="Str_LosslessParamTooltip">无损压缩, 会忽略质量参数, 仅对 Webp 格式生效</s:String>
<s:String x:Key="Str_CrfParameter">CRF 参数</s:String>
<s:String x:Key="Str_CrfParameterTooltip">CRF 参数,取值范围 0-63越小质量越高仅对 Mp4/Webm/Mkv 格式生效</s:String>
@@ -182,7 +187,7 @@
<s:String x:Key="Str_FFmpegCodec">编码器</s:String>
<s:String x:Key="Str_FFmpegCodecTooltip">FFmpeg 编码器,等价于参数 “-c:v”例如 “libx264”、“libx265”</s:String>
<s:String x:Key="Str_FFmpegPixelFormat">像素格式</s:String>
<s:String x:Key="Str_FFmpegPixelFormatTooltip">FFmpeg 像素格式,等价于参数 “-pix_fmt”例如 “yuv420”、“yuv444”</s:String>
<s:String x:Key="Str_FFmpegPixelFormatTooltip">FFmpeg 像素格式,等价于参数 “-pix_fmt”例如 “yuv420p”、“yuv444p”</s:String>
<s:String x:Key="Str_FFmpegBitrate">比特率</s:String>
<s:String x:Key="Str_FFmpegBitrateTooltip">FFmpeg 比特率,等价于参数 “-b:v”例如 “6K”、“2M”</s:String>
<s:String x:Key="Str_FFmpegFilter">滤镜</s:String>

View File

@@ -7,7 +7,7 @@
<TargetFramework>net8.0-windows</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>0.15.2</Version>
<Version>0.15.7</Version>
<OutputType>WinExe</OutputType>
<UseWPF>true</UseWPF>
</PropertyGroup>

View File

@@ -54,7 +54,7 @@ namespace SpineViewer.ViewModels.Exporters
/// 背景颜色
/// </summary>
public Color BackgroundColor { get => _backgroundColor; set => SetProperty(ref _backgroundColor, value); }
protected Color _backgroundColor = Color.FromArgb(0, 0, 0, 0);
protected Color _backgroundColor = Color.FromArgb(255, 0, 0, 0);
/// <summary>
/// 四周边缘距离
@@ -142,7 +142,6 @@ namespace SpineViewer.ViewModels.Exporters
{
if (!Export_CanExecute(args)) return;
Export(args.Cast<SpineObjectModel>().ToArray());
// XXX: 导出途中应该停掉渲染好一些, 让性能专注在导出上
}
private bool Export_CanExecute(IList? args)

View File

@@ -64,6 +64,7 @@ namespace SpineViewer.ViewModels.Exporters
{
BackgroundColor = new(_backgroundColor.R, _backgroundColor.G, _backgroundColor.B, _backgroundColor.A),
Fps = _fps,
Speed = _speed,
KeepLast = _keepLast,
Format = _format,
Codec = _codec,
@@ -83,13 +84,17 @@ namespace SpineViewer.ViewModels.Exporters
exporter.Rotation = view.Rotation;
}
_vmMain.SFMLRendererViewModel.StopRender();
if (_exportSingle)
{
var filename = $"ffmpeg_{timestamp}_{Guid.NewGuid().ToString()[..6]}_{_fps}{FormatSuffix}";
var output = Path.Combine(_outputDir!, filename);
if (_autoResolution) SetAutoResolutionAnimated(exporter, spines);
if (_duration < 0) exporter.Duration = spines.Select(sp => sp.GetAnimationMaxDuration()).DefaultIfEmpty(0).Max();
// 如果时长是一个负数值则使用所有动画时长的最大值
exporter.Duration = _duration < 0 ? spines.Select(sp => sp.GetAnimationMaxDuration()).DefaultIfEmpty(0).Max() : _duration;
exporter.ProgressReporter = (total, done, text) =>
{
@@ -116,12 +121,7 @@ namespace SpineViewer.ViewModels.Exporters
{
// 统计总帧数
int totalFrameCount = 0;
if (_duration > 0)
{
exporter.Duration = _duration;
totalFrameCount = exporter.GetFrameCount() * spines.Length;
}
else
if (_duration < 0)
{
foreach (var sp in spines)
{
@@ -129,6 +129,11 @@ namespace SpineViewer.ViewModels.Exporters
totalFrameCount += exporter.GetFrameCount();
}
}
else
{
exporter.Duration = _duration;
totalFrameCount = exporter.GetFrameCount() * spines.Length;
}
pr.Total = totalFrameCount;
pr.Done = 0;
@@ -151,7 +156,9 @@ namespace SpineViewer.ViewModels.Exporters
}
if (_autoResolution) SetAutoResolutionAnimated(exporter, sp);
if (_duration <= 0) exporter.Duration = sp.GetAnimationMaxDuration();
// 如果时长是负数则需要每次都设置成动画的时长值, 否则前面统计帧数时已经设置过时长值
if (_duration < 0) exporter.Duration = sp.GetAnimationMaxDuration();
var filename = $"{sp.Name}_{timestamp}_{Guid.NewGuid().ToString()[..6]}_{_fps}{FormatSuffix}";
var output = Path.Combine(_outputDir ?? sp.AssetsDir, filename);
@@ -168,6 +175,8 @@ namespace SpineViewer.ViewModels.Exporters
}
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.None;
}
_vmMain.SFMLRendererViewModel.StartRender();
}
}
}

View File

@@ -29,6 +29,9 @@ namespace SpineViewer.ViewModels.Exporters
public int Quality { get => _quality; set => SetProperty(ref _quality, Math.Clamp(value, 0, 100)); }
protected int _quality = 75;
public bool Lossless { get => _lossless; set => SetProperty(ref _lossless, value); }
protected bool _lossless = false;
public int Crf { get => _crf; set => SetProperty(ref _crf, Math.Clamp(value, 0, 63)); }
protected int _crf = 23;
@@ -51,10 +54,12 @@ namespace SpineViewer.ViewModels.Exporters
{
BackgroundColor = new(_backgroundColor.R, _backgroundColor.G, _backgroundColor.B, _backgroundColor.A),
Fps = _fps,
Speed = _speed,
KeepLast = _keepLast,
Format = _format,
Loop = _loop,
Quality = _quality,
Lossless = _lossless,
Crf = _crf
};
@@ -68,13 +73,19 @@ namespace SpineViewer.ViewModels.Exporters
exporter.Rotation = view.Rotation;
}
// BUG: FFmpeg 导出时对 RenderTexture 的频繁资源申请释放似乎使 SFML 库内部出现问题, 会卡死所有使用 SFML 的地方, 包括渲染线程
// 所以临时把渲染线程停掉, 只让此处使用 SFML 资源, 这个问题或许和多个线程同时使用渲染资源有关
_vmMain.SFMLRendererViewModel.StopRender();
if (_exportSingle)
{
var filename = $"video_{timestamp}_{Guid.NewGuid().ToString()[..6]}_{_fps}{FormatSuffix}";
var output = Path.Combine(_outputDir!, filename);
if (_autoResolution) SetAutoResolutionAnimated(exporter, spines);
if (_duration < 0) exporter.Duration = spines.Select(sp => sp.GetAnimationMaxDuration()).DefaultIfEmpty(0).Max();
// 如果时长是一个负数值则使用所有动画时长的最大值
exporter.Duration = _duration < 0 ? spines.Select(sp => sp.GetAnimationMaxDuration()).DefaultIfEmpty(0).Max() : _duration;
exporter.ProgressReporter = (total, done, text) =>
{
@@ -101,12 +112,7 @@ namespace SpineViewer.ViewModels.Exporters
{
// 统计总帧数
int totalFrameCount = 0;
if (_duration > 0)
{
exporter.Duration = _duration;
totalFrameCount = exporter.GetFrameCount() * spines.Length;
}
else
if (_duration < 0)
{
foreach (var sp in spines)
{
@@ -114,6 +120,11 @@ namespace SpineViewer.ViewModels.Exporters
totalFrameCount += exporter.GetFrameCount();
}
}
else
{
exporter.Duration = _duration;
totalFrameCount = exporter.GetFrameCount() * spines.Length;
}
pr.Total = totalFrameCount;
pr.Done = 0;
@@ -136,7 +147,9 @@ namespace SpineViewer.ViewModels.Exporters
}
if (_autoResolution) SetAutoResolutionAnimated(exporter, sp);
if (_duration <= 0) exporter.Duration = sp.GetAnimationMaxDuration();
// 如果时长是负数则需要每次都设置成动画的时长值, 否则前面统计帧数时已经设置过时长值
if (_duration < 0) exporter.Duration = sp.GetAnimationMaxDuration();
var filename = $"{sp.Name}_{timestamp}_{Guid.NewGuid().ToString()[..6]}_{_fps}{FormatSuffix}";
var output = Path.Combine(_outputDir ?? sp.AssetsDir, filename);
@@ -153,6 +166,8 @@ namespace SpineViewer.ViewModels.Exporters
}
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.None;
}
_vmMain.SFMLRendererViewModel.StartRender();
}
}
}

View File

@@ -34,6 +34,7 @@ namespace SpineViewer.ViewModels.Exporters
{
BackgroundColor = new(_backgroundColor.R, _backgroundColor.G, _backgroundColor.B, _backgroundColor.A),
Fps = _fps,
Speed = _speed,
KeepLast = _keepLast
};
@@ -47,18 +48,22 @@ namespace SpineViewer.ViewModels.Exporters
exporter.Rotation = view.Rotation;
}
_vmMain.SFMLRendererViewModel.StopRender();
if (_exportSingle)
{
var folderName = $"frames_{timestamp}_{Guid.NewGuid().ToString()[..6]}_{_fps}";
var output = Path.Combine(_outputDir!, folderName);
if (_autoResolution) SetAutoResolutionAnimated(exporter, spines);
if (_duration < 0) exporter.Duration = spines.Select(sp => sp.GetAnimationMaxDuration()).DefaultIfEmpty(0).Max();
// 如果时长是一个负数值则使用所有动画时长的最大值
exporter.Duration = _duration < 0 ? spines.Select(sp => sp.GetAnimationMaxDuration()).DefaultIfEmpty(0).Max() : _duration;
exporter.ProgressReporter = (total, done, text) =>
{
pr.Total = total;
pr.Done = done;
pr.Total = total;
pr.Done = done;
pr.ProgressText = text;
_vmMain.ProgressValue = pr.Done / pr.Total;
};
@@ -80,12 +85,7 @@ namespace SpineViewer.ViewModels.Exporters
{
// 统计总帧数
int totalFrameCount = 0;
if (_duration > 0)
{
exporter.Duration = _duration;
totalFrameCount = exporter.GetFrameCount() * spines.Length;
}
else
if (_duration < 0)
{
foreach (var sp in spines)
{
@@ -93,6 +93,11 @@ namespace SpineViewer.ViewModels.Exporters
totalFrameCount += exporter.GetFrameCount();
}
}
else
{
exporter.Duration = _duration;
totalFrameCount = exporter.GetFrameCount() * spines.Length;
}
pr.Total = totalFrameCount;
pr.Done = 0;
@@ -115,7 +120,9 @@ namespace SpineViewer.ViewModels.Exporters
}
if (_autoResolution) SetAutoResolutionAnimated(exporter, sp);
if (_duration <= 0) exporter.Duration = sp.GetAnimationMaxDuration();
// 如果时长是负数则需要每次都设置成动画的时长值, 否则前面统计帧数时已经设置过时长值
if (_duration < 0) exporter.Duration = sp.GetAnimationMaxDuration();
var folderName = $"{sp.Name}_{timestamp}_{Guid.NewGuid().ToString()[..6]}_{_fps}";
var output = Path.Combine(_outputDir ?? sp.AssetsDir, folderName);
@@ -132,6 +139,8 @@ namespace SpineViewer.ViewModels.Exporters
}
_vmMain.ProgressState = System.Windows.Shell.TaskbarItemProgressState.None;
}
_vmMain.SFMLRendererViewModel.StartRender();
}
}
}

View File

@@ -16,6 +16,9 @@ namespace SpineViewer.ViewModels.Exporters
public uint Fps { get => _fps; set => SetProperty(ref _fps, Math.Max(1, value)); }
protected uint _fps = 30;
public float Speed { get => _speed; set => SetProperty(ref _speed, Math.Clamp(value, 0.001f, 1000f)); }
protected float _speed = 1f;
public bool KeepLast { get => _keepLast; set => SetProperty(ref _keepLast, value); }
protected bool _keepLast = true;
}

View File

@@ -140,6 +140,13 @@ namespace SpineViewer.ViewModels.MainWindow
set => SetProperty(_renderer.MaxFps, value, v => _renderer.MaxFps = value);
}
public float Speed
{
get => _speed;
set => SetProperty(ref _speed, Math.Clamp(value, 0.01f, 100f));
}
private float _speed = 1f;
public bool ShowAxis
{
get => _showAxis;
@@ -193,15 +200,15 @@ namespace SpineViewer.ViewModels.MainWindow
});
private RelayCommand? _cmd_Restart;
public RelayCommand Cmd_ForwardStep => _cmd_ForwardStep ??= new(() =>
{
lock (_forwardDeltaLock) _forwardDelta += _renderer.MaxFps > 0 ? 1f / _renderer.MaxFps : 0.001f;
public RelayCommand Cmd_ForwardStep => _cmd_ForwardStep ??= new(() =>
{
lock (_forwardDeltaLock) _forwardDelta += _renderer.MaxFps > 0 ? 1f / _renderer.MaxFps : 0.001f;
});
private RelayCommand? _cmd_ForwardStep;
public RelayCommand Cmd_ForwardFast => _cmd_ForwardFast ??= new(() =>
{
lock (_forwardDeltaLock) _forwardDelta += _renderer.MaxFps > 0 ? 10f / _renderer.MaxFps : 0.01f;
{
lock (_forwardDeltaLock) _forwardDelta += _renderer.MaxFps > 0 ? 10f / _renderer.MaxFps : 0.01f;
});
private RelayCommand? _cmd_ForwardFast;
@@ -390,7 +397,7 @@ namespace SpineViewer.ViewModels.MainWindow
if (_cancelToken?.IsCancellationRequested ?? true) break; // 提前中止
sp.Update(0); // 避免物理效果出现问题
sp.Update(delta);
sp.Update(delta * _speed);
// 为选中对象绘制一个半透明背景
if (sp.IsSelected)
@@ -426,7 +433,7 @@ namespace SpineViewer.ViewModels.MainWindow
}
public RendererWorkspaceConfigModel WorkspaceConfig
{
{
// TODO: 背景图片
get
{
@@ -441,23 +448,25 @@ namespace SpineViewer.ViewModels.MainWindow
FlipX = FlipX,
FlipY = FlipY,
MaxFps = MaxFps,
Speed = Speed,
ShowAxis = ShowAxis,
BackgroundColor = BackgroundColor,
};
}
set
{
ResolutionX = value.ResolutionX;
ResolutionY = value.ResolutionY;
CenterX = value.CenterX;
CenterY = value.CenterY;
Zoom = value.Zoom;
Rotation = value.Rotation;
FlipX = value.FlipX;
FlipY = value.FlipY;
MaxFps = value.MaxFps;
ShowAxis = value.ShowAxis;
BackgroundColor = value.BackgroundColor;
ResolutionX = value.ResolutionX;
ResolutionY = value.ResolutionY;
CenterX = value.CenterX;
CenterY = value.CenterY;
Zoom = value.Zoom;
Rotation = value.Rotation;
FlipX = value.FlipX;
FlipY = value.FlipY;
MaxFps = value.MaxFps;
Speed = value.Speed;
ShowAxis = value.ShowAxis;
BackgroundColor = value.BackgroundColor;
}
}
}

View File

@@ -101,7 +101,7 @@ namespace SpineViewer.ViewModels.MainWindow
private void AddSpineObject_Execute()
{
MessagePopupService.Info("Not Implemented, try next version :)");
MessagePopupService.Info("Not Implemented, please drag files into here or add them from clipboard :)");
}
/// <summary>

View File

@@ -67,6 +67,7 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="60"/>
</Grid.RowDefinitions>
@@ -122,38 +123,42 @@
<Label Grid.Row="10" Grid.Column="0" Content="{DynamicResource Str_Fps}"/>
<TextBox Grid.Row="10" Grid.Column="1" Text="{Binding Fps}"/>
<!-- 是否保留最后一帧 -->
<Label Grid.Row="11" Grid.Column="0" Content="{DynamicResource Str_KeepLastFrame}" ToolTip="{DynamicResource Str_KeepLastFrameTooltip}"/>
<ToggleButton Grid.Row="11" Grid.Column="1" IsChecked="{Binding KeepLast}" ToolTip="{DynamicResource Str_KeelLastFrameTooltip}"/>
<!-- 导出速度 -->
<Label Grid.Row="11" Grid.Column="0" Content="{DynamicResource Str_ExportSpeed}" ToolTip="{DynamicResource Str_ExportSpeedTooltip}"/>
<TextBox Grid.Row="11" Grid.Column="1" Text="{Binding Speed}" ToolTip="{DynamicResource Str_ExportSpeedTooltip}"/>
<Separator Grid.Row="12" Grid.ColumnSpan="2" Height="10"/>
<!-- 是否保留最后一帧 -->
<Label Grid.Row="12" Grid.Column="0" Content="{DynamicResource Str_KeepLastFrame}" ToolTip="{DynamicResource Str_KeepLastFrameTooltip}"/>
<ToggleButton Grid.Row="12" Grid.Column="1" IsChecked="{Binding KeepLast}" ToolTip="{DynamicResource Str_KeelLastFrameTooltip}"/>
<Separator Grid.Row="13" Grid.ColumnSpan="2" Height="10"/>
<!-- 导出格式 -->
<Label Grid.Row="13" Grid.Column="0" Content="{DynamicResource Str_FFmpegFormat}" ToolTip="{DynamicResource Str_FFmpegFormatTooltip}"/>
<TextBox Grid.Row="13" Grid.Column="1" Text="{Binding Format}" ToolTip="{DynamicResource Str_FFmpegFormatTooltip}"/>
<Label Grid.Row="14" Grid.Column="0" Content="{DynamicResource Str_FFmpegFormat}" ToolTip="{DynamicResource Str_FFmpegFormatTooltip}"/>
<TextBox Grid.Row="14" Grid.Column="1" Text="{Binding Format}" ToolTip="{DynamicResource Str_FFmpegFormatTooltip}"/>
<!-- 编码器 -->
<Label Grid.Row="14" Grid.Column="0" Content="{DynamicResource Str_FFmpegCodec}" ToolTip="{DynamicResource Str_FFmpegCodecTooltip}"/>
<TextBox Grid.Row="14" Grid.Column="1" Text="{Binding Codec}" ToolTip="{DynamicResource Str_FFmpegCodecTooltip}"/>
<Label Grid.Row="15" Grid.Column="0" Content="{DynamicResource Str_FFmpegCodec}" ToolTip="{DynamicResource Str_FFmpegCodecTooltip}"/>
<TextBox Grid.Row="15" Grid.Column="1" Text="{Binding Codec}" ToolTip="{DynamicResource Str_FFmpegCodecTooltip}"/>
<!-- 像素格式 -->
<Label Grid.Row="15" Grid.Column="0" Content="{DynamicResource Str_FFmpegPixelFormat}" ToolTip="{DynamicResource Str_FFmpegPixelFormatTooltip}"/>
<TextBox Grid.Row="15" Grid.Column="1" Text="{Binding PixelFormat}" ToolTip="{DynamicResource Str_FFmpegPixelFormatTooltip}"/>
<Label Grid.Row="16" Grid.Column="0" Content="{DynamicResource Str_FFmpegPixelFormat}" ToolTip="{DynamicResource Str_FFmpegPixelFormatTooltip}"/>
<TextBox Grid.Row="16" Grid.Column="1" Text="{Binding PixelFormat}" ToolTip="{DynamicResource Str_FFmpegPixelFormatTooltip}"/>
<!-- 比特率 -->
<Label Grid.Row="16" Grid.Column="0" Content="{DynamicResource Str_FFmpegBitrate}" ToolTip="{DynamicResource Str_FFmpegBitrateTooltip}"/>
<TextBox Grid.Row="16" Grid.Column="1" Text="{Binding Bitrate}" ToolTip="{DynamicResource Str_FFmpegBitrateTooltip}"/>
<Label Grid.Row="17" Grid.Column="0" Content="{DynamicResource Str_FFmpegBitrate}" ToolTip="{DynamicResource Str_FFmpegBitrateTooltip}"/>
<TextBox Grid.Row="17" Grid.Column="1" Text="{Binding Bitrate}" ToolTip="{DynamicResource Str_FFmpegBitrateTooltip}"/>
<!-- 滤镜 -->
<Label Grid.Row="17" Grid.Column="0" Content="{DynamicResource Str_FFmpegFilter}" ToolTip="{DynamicResource Str_FFmpegFilterTooltip}"/>
<TextBox Grid.Row="17" Grid.Column="1" Text="{Binding Filter}" ToolTip="{DynamicResource Str_FFmpegFilterTooltip}"/>
<Label Grid.Row="18" Grid.Column="0" Content="{DynamicResource Str_FFmpegFilter}" ToolTip="{DynamicResource Str_FFmpegFilterTooltip}"/>
<TextBox Grid.Row="18" Grid.Column="1" Text="{Binding Filter}" ToolTip="{DynamicResource Str_FFmpegFilterTooltip}"/>
<!-- 自定义参数 -->
<Label Grid.Row="18" Grid.Column="0"
<Label Grid.Row="19" Grid.Column="0"
VerticalAlignment="Top"
Content="{DynamicResource Str_FFmpegCustomArgs}"
ToolTip="{DynamicResource Str_FFmpegCustomArgsTooltip}"/>
<TextBox Grid.Row="18" Grid.Column="1"
<TextBox Grid.Row="19" Grid.Column="1"
HorizontalContentAlignment="Left"
VerticalContentAlignment="Top"
TextWrapping="Wrap"

View File

@@ -9,7 +9,7 @@
mc:Ignorable="d"
Title="{DynamicResource Str_FFmpegVideoExporterTitle}"
Width="450"
Height="550"
Height="580"
ShowInTaskbar="False"
WindowStartupLocation="CenterOwner">
<DockPanel>
@@ -66,6 +66,8 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 水平分辨率 -->
@@ -120,27 +122,35 @@
<Label Grid.Row="10" Grid.Column="0" Content="{DynamicResource Str_Fps}"/>
<TextBox Grid.Row="10" Grid.Column="1" Text="{Binding Fps}"/>
<!-- 是否保留最后一帧 -->
<Label Grid.Row="11" Grid.Column="0" Content="{DynamicResource Str_KeepLastFrame}" ToolTip="{DynamicResource Str_KeepLastFrameTooltip}"/>
<ToggleButton Grid.Row="11" Grid.Column="1" IsChecked="{Binding KeepLast}" ToolTip="{DynamicResource Str_KeelLastFrameTooltip}"/>
<!-- 导出速度 -->
<Label Grid.Row="11" Grid.Column="0" Content="{DynamicResource Str_ExportSpeed}" ToolTip="{DynamicResource Str_ExportSpeedTooltip}"/>
<TextBox Grid.Row="11" Grid.Column="1" Text="{Binding Speed}" ToolTip="{DynamicResource Str_ExportSpeedTooltip}"/>
<Separator Grid.Row="12" Grid.ColumnSpan="2" Height="10"/>
<!-- 是否保留最后一帧 -->
<Label Grid.Row="12" Grid.Column="0" Content="{DynamicResource Str_KeepLastFrame}" ToolTip="{DynamicResource Str_KeepLastFrameTooltip}"/>
<ToggleButton Grid.Row="12" Grid.Column="1" IsChecked="{Binding KeepLast}" ToolTip="{DynamicResource Str_KeelLastFrameTooltip}"/>
<Separator Grid.Row="13" Grid.ColumnSpan="2" Height="10"/>
<!-- 视频格式 -->
<Label Grid.Row="13" Grid.Column="0" Content="{DynamicResource Str_VideoFormat}"/>
<ComboBox Grid.Row="13" Grid.Column="1" SelectedItem="{Binding Format}" ItemsSource="{Binding VideoFormatOptions}"/>
<Label Grid.Row="14" Grid.Column="0" Content="{DynamicResource Str_VideoFormat}"/>
<ComboBox Grid.Row="14" Grid.Column="1" SelectedItem="{Binding Format}" ItemsSource="{Binding VideoFormatOptions}"/>
<!-- 动图是否循环 -->
<Label Grid.Row="14" Grid.Column="0" Content="{DynamicResource Str_LoopPlay}" ToolTip="{DynamicResource Str_LoopPlayTooltip}"/>
<ToggleButton Grid.Row="14" Grid.Column="1" IsChecked="{Binding Loop}" ToolTip="{DynamicResource Str_LoopPlayTooltip}"/>
<Label Grid.Row="15" Grid.Column="0" Content="{DynamicResource Str_LoopPlay}" ToolTip="{DynamicResource Str_LoopPlayTooltip}"/>
<ToggleButton Grid.Row="15" Grid.Column="1" IsChecked="{Binding Loop}" ToolTip="{DynamicResource Str_LoopPlayTooltip}"/>
<!-- 质量参数 -->
<Label Grid.Row="15" Grid.Column="0" Content="{DynamicResource Str_QualityParameter}" ToolTip="{DynamicResource Str_QualityParameterTooltip}"/>
<TextBox Grid.Row="15" Grid.Column="1" Text="{Binding Quality}" ToolTip="{DynamicResource Str_QualityParameterTooltip}"/>
<Label Grid.Row="16" Grid.Column="0" Content="{DynamicResource Str_QualityParameter}" ToolTip="{DynamicResource Str_QualityParameterTooltip}"/>
<TextBox Grid.Row="16" Grid.Column="1" Text="{Binding Quality}" ToolTip="{DynamicResource Str_QualityParameterTooltip}"/>
<!-- 无损压缩 -->
<Label Grid.Row="17" Grid.Column="0" Content="{DynamicResource Str_LosslessParam}" ToolTip="{DynamicResource Str_LosslessParamTooltip}"/>
<ToggleButton Grid.Row="17" Grid.Column="1" IsChecked="{Binding Lossless}" ToolTip="{DynamicResource Str_LosslessParamTooltip}"/>
<!-- CRF 参数 -->
<Label Grid.Row="16" Grid.Column="0" Content="{DynamicResource Str_CrfParameter}" ToolTip="{DynamicResource Str_CrfParameterTooltip}"/>
<TextBox Grid.Row="16" Grid.Column="1" Text="{Binding Crf}" ToolTip="{DynamicResource Str_CrfParameterTooltip}"/>
<Label Grid.Row="18" Grid.Column="0" Content="{DynamicResource Str_CrfParameter}" ToolTip="{DynamicResource Str_CrfParameterTooltip}"/>
<TextBox Grid.Row="18" Grid.Column="1" Text="{Binding Crf}" ToolTip="{DynamicResource Str_CrfParameterTooltip}"/>
</Grid>
</ScrollViewer>

View File

@@ -62,6 +62,7 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 水平分辨率 -->
@@ -116,9 +117,13 @@
<Label Grid.Row="10" Grid.Column="0" Content="{DynamicResource Str_Fps}"/>
<TextBox Grid.Row="10" Grid.Column="1" Text="{Binding Fps}"/>
<!-- 导出速度 -->
<Label Grid.Row="11" Grid.Column="0" Content="{DynamicResource Str_ExportSpeed}" ToolTip="{DynamicResource Str_ExportSpeedTooltip}"/>
<TextBox Grid.Row="11" Grid.Column="1" Text="{Binding Speed}" ToolTip="{DynamicResource Str_ExportSpeedTooltip}"/>
<!-- 是否保留最后一帧 -->
<Label Grid.Row="11" Grid.Column="0" Content="{DynamicResource Str_KeepLastFrame}" ToolTip="{DynamicResource Str_KeepLastFrameTooltip}"/>
<ToggleButton Grid.Row="11" Grid.Column="1" IsChecked="{Binding KeepLast}" ToolTip="{DynamicResource Str_KeelLastFrameTooltip}"/>
<Label Grid.Row="12" Grid.Column="0" Content="{DynamicResource Str_KeepLastFrame}" ToolTip="{DynamicResource Str_KeepLastFrameTooltip}"/>
<ToggleButton Grid.Row="12" Grid.Column="1" IsChecked="{Binding KeepLast}" ToolTip="{DynamicResource Str_KeelLastFrameTooltip}"/>
</Grid>
</ScrollViewer>
</Border>

View File

@@ -693,6 +693,7 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 水平分辨率 -->
@@ -731,13 +732,17 @@
<Label Grid.Row="8" Grid.Column="0" Content="{DynamicResource Str_MaxFps}"/>
<TextBox Grid.Row="8" Grid.Column="1" Text="{Binding MaxFps}"/>
<!-- 播放速度 -->
<Label Grid.Row="9" Grid.Column="0" Content="{DynamicResource Str_PlaySpeed}"/>
<TextBox Grid.Row="9" Grid.Column="1" Text="{Binding Speed}"/>
<!-- 显示坐标轴 -->
<Label Grid.Row="9" Grid.Column="0" Content="{DynamicResource Str_ShowAxis}"/>
<ToggleButton Grid.Row="9" Grid.Column="1" IsChecked="{Binding ShowAxis}"/>
<Label Grid.Row="10" Grid.Column="0" Content="{DynamicResource Str_ShowAxis}"/>
<ToggleButton Grid.Row="10" Grid.Column="1" IsChecked="{Binding ShowAxis}"/>
<!-- 背景颜色 -->
<Label Grid.Row="10" Grid.Column="0" Content="{DynamicResource Str_BackgroundColor}" ToolTip="#AARRGGBB"/>
<TextBox Grid.Row="10" Grid.Column="1" Text="{Binding BackgroundColor}" ToolTip="#AARRGGBB"/>
<Label Grid.Row="11" Grid.Column="0" Content="{DynamicResource Str_BackgroundColor}" ToolTip="#AARRGGBB"/>
<TextBox Grid.Row="11" Grid.Column="1" Text="{Binding BackgroundColor}" ToolTip="#AARRGGBB"/>
<!-- 背景图案 -->
<!-- 背景图案模式 -->

View File

@@ -0,0 +1,240 @@
using System.Globalization;
using System.IO;
using SFML.Graphics;
using SFML.System;
using Spine;
using Spine.Exporters;
namespace SpineViewerCLI
{
public class CLI
{
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]
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)
{
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;
for (int i = 0; i < args.Length; i++)
{
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;
}
}
if (string.IsNullOrEmpty(skelPath))
{
Console.Error.WriteLine("Missing --skel");
Environment.Exit(2);
}
if (string.IsNullOrEmpty(output))
{
Console.Error.WriteLine("Missing --output");
Environment.Exit(2);
}
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)
{
var spineObject = new SpineObject(sp, true);
foreach (var tr in sp.AnimationState.IterTracks().Where(t => t is not null))
{
var t = spineObject.AnimationState.SetAnimation(tr!.TrackIndex, tr.Animation, tr.Loop);
}
spineObject.Update(0);
return spineObject;
}
static FloatRect GetSpineObjectBounds(SpineObject sp)
{
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)
{
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);
innerW *= scaleW;
innerH *= scaleH;
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);
}
}
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<TargetFramework>net8.0-windows</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>0.0.1</Version>
<OutputType>Exe</OutputType>
</PropertyGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);NETSDK1206</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SFMLRenderer\SFMLRenderer.csproj" />
<ProjectReference Include="..\Spine\Spine.csproj" />
</ItemGroup>
</Project>