Compare commits

..

81 Commits

Author SHA1 Message Date
ww-rm
65508782c6 Merge pull request #77 from ww-rm/dev/wpf
v0.15.8
2025-08-01 00:03:42 +08:00
ww-rm
d02ab536b6 更新至v0.15.8 2025-08-01 00:02:26 +08:00
ww-rm
db3700bda3 update readme 2025-08-01 00:02:21 +08:00
ww-rm
6dea656e5e 修复可能的null引用报错 2025-08-01 00:00:54 +08:00
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
ww-rm
3ca22c3f00 Merge pull request #55 from ww-rm/dev/wpf
Dev/wpf
2025-06-19 23:22:35 +08:00
ww-rm
593d9b771c 更新至v0.15.2 2025-06-19 23:21:43 +08:00
ww-rm
35f9357355 update changelog 2025-06-19 23:20:20 +08:00
ww-rm
427d18df4c 工作区保存参数增加浏览路径 2025-06-19 23:19:04 +08:00
ww-rm
7d1a1f1aeb 修复首选项文件不存在时的提示信息 2025-06-19 22:54:12 +08:00
ww-rm
d7017f8984 Merge pull request #54 from ww-rm/dev/wpf
修复工作流版本号提取错误
2025-06-18 01:52:58 +08:00
ww-rm
7b58eeafe3 修复工作流版本号提取错误 2025-06-18 01:52:08 +08:00
ww-rm
0e2eb3fbb1 Merge pull request #53 from ww-rm/dev/wpf
Dev/wpf
2025-06-18 01:39:18 +08:00
ww-rm
1f65dfb854 更新至v0.15.1 2025-06-18 01:38:16 +08:00
ww-rm
522bd26581 update readme 2025-06-18 01:37:58 +08:00
ww-rm
8849ddec54 增加对Color的定制序列化逻辑 2025-06-18 01:14:53 +08:00
ww-rm
5039bc666f 增加工作区功能 2025-06-18 00:53:02 +08:00
ww-rm
7bd3e3669b 增加参数保存功能 2025-06-15 14:38:37 +08:00
ww-rm
0dcf8c5577 增加显示和渲染选中首选项 2025-06-15 11:08:43 +08:00
ww-rm
9a62d7eb53 增加骨骼文件重载功能 2025-06-15 01:12:16 +08:00
ww-rm
5f189a066d 修复静态画面的边界检测错误 2025-06-14 20:00:25 +08:00
ww-rm
2287542522 small change 2025-06-14 11:33:00 +08:00
ww-rm
333c5e9981 模型属性面板项进行排序 2025-06-14 11:26:09 +08:00
ww-rm
0e7e7dd5d9 修改LoadOptions的归属 2025-06-14 11:14:27 +08:00
ww-rm
6fe639d6dd 允许导出单个时自动使用所有模型的所有动画最大时长 2025-06-14 10:48:38 +08:00
ww-rm
6114d4c954 修改布局 2025-06-14 02:01:00 +08:00
ww-rm
7ec938b415 增加语言设置 2025-06-14 01:57:39 +08:00
ww-rm
b3010360b4 增加首选项对话框 2025-06-13 23:12:15 +08:00
ww-rm
125ce6fa86 去除构造函数里的版本参数 2025-06-13 23:09:53 +08:00
ww-rm
7f61ebda78 修改setproperty调用方式 2025-06-13 00:35:59 +08:00
ww-rm
1092f37a02 移除一些非必要默认值 2025-06-13 00:11:03 +08:00
ww-rm
9be77ba8bd 增加纹理加载选项 2025-06-12 23:47:46 +08:00
ww-rm
28fd11cf3e 修复骨骼文件尝试性读取错误 2025-06-06 22:02:22 +08:00
ww-rm
16d4388f3e 修一下缺省值 2025-05-29 21:55:47 +08:00
ww-rm
42cb782a96 移除winforms引用 2025-05-29 20:10:15 +08:00
ww-rm
fffe69c49f 修改结构 2025-05-29 19:49:06 +08:00
ww-rm
707bdf7d33 增加弹框样式 2025-05-29 19:36:15 +08:00
ww-rm
54f9a054cf 标题增加v 2025-05-28 19:47:43 +08:00
ww-rm
550dafb2c2 修改调试渲染逻辑和选中时效果 2025-05-28 19:37:22 +08:00
ww-rm
5aaca437af 更新构建发布工作流自动检测版本号 2025-05-28 18:43:42 +08:00
75 changed files with 2756 additions and 840 deletions

View File

@@ -1,46 +1,83 @@
name: Build & Release
on:
push:
tags:
- 'v*.*.*'
pull_request:
branches:
- main
types:
- closed
jobs:
build-release:
if: ${{ github.event.pull_request.merged == true }}
runs-on: windows-latest
env:
PROJECT_NAME: SpineViewer
VERSION: ${{ github.ref_name }}
PROJ_CLI_NAME: SpineViewerCLI
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'
dotnet-version: "8.0.x"
- name: Extract version from csproj
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"
- name: Tag merge commit
shell: pwsh
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag $env:VERSION
git push --tags
- name: Publish FrameworkDependent version
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: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: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
run: mkdir release
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
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
Compress-Archive -Path "publish\$env:PROJECT_NAME-$env:VERSION-SelfContained\*" -DestinationPath "release\$env:PROJECT_NAME-$env:VERSION-SelfContained.zip" -Force
- name: Create GitHub Release
id: create_release
uses: actions/create-release@v1
@@ -51,7 +88,7 @@ jobs:
release_name: Release ${{ env.VERSION }}
draft: false
prerelease: false
- name: Upload FrameworkDependent zip
uses: actions/upload-release-asset@v1
env:
@@ -61,7 +98,7 @@ jobs:
asset_path: release/${{ env.PROJECT_NAME }}-${{ env.VERSION }}.zip
asset_name: ${{ env.PROJECT_NAME }}-${{ env.VERSION }}.zip
asset_content_type: application/zip
- name: Upload SelfContained zip
uses: actions/upload-release-asset@v1
env:

View File

@@ -1,5 +1,44 @@
# CHANGELOG
## v0.15.8
- 修复渲染纹理过程中可能的 null 错误
## v0.15.7
- 合并社区 CLI 功能项目
## v0.15.6
- 修复导出单个的时长错误
- 修改默认导出背景色为不透明黑色
## v0.15.5
- 修复自定义导出时的画面错误
- 设置 mp4 像素格式为 yuv420p 避免 windows 默认播放器无法打开
- 增加预览画面和导出时的速度参数设置
- 修复一些提示文本错误
- 导出时自动将分辨率向下调整为 2 的倍数, 避免 yuv420p 格式出错
## v0.15.4
- 修复导出时可能的卡死问题
- 增加 webp 格式无损压缩参数
## v0.15.3
- 增加 skel.bytes 后缀识别
## v0.15.2
- 修复首选项文件读取为空时的提示信息
- 工作区参数增加浏览路径
## v0.15.1
- 新版本正式发布
## v0.15.0
### 项目分支变更

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.0</Version>
<Version>0.15.4</Version>
<UseWPF>true</UseWPF>
</PropertyGroup>

View File

@@ -1,111 +1,133 @@
# [SpineViewer](https://github.com/ww-rm/SpineViewer)
[![Build and Release](https://github.com/ww-rm/SpineViewer/actions/workflows/dotnet-desktop.yml/badge.svg)](https://github.com/ww-rm/SpineViewer/actions/workflows/dotnet-desktop.yml)
[![GitHub Release](https://img.shields.io/github/v/release/ww-rm/SpineViewer?logo=github&logoColor=959da5&label=Release&labelColor=3f4850)](https://github.com/ww-rm/SpineViewer/releases)
[![Downloads](https://img.shields.io/github/downloads/ww-rm/SpineViewer/total?logo=github&logoColor=959da5&label=Downloads&labelColor=3f4850)](https://github.com/ww-rm/SpineViewer/releases)
[![Build and Release](https://github.com/ww-rm/SpineViewer/actions/workflows/dotnet-desktop.yml/badge.svg)](https://github.com/ww-rm/SpineViewer/actions/workflows/dotnet-desktop.yml)
[![GitHub Release](https://img.shields.io/github/v/release/ww-rm/SpineViewer?logo=github\&logoColor=959da5\&label=Release\&labelColor=3f4850)](https://github.com/ww-rm/SpineViewer/releases)
[![Downloads](https://img.shields.io/github/downloads/ww-rm/SpineViewer/total?logo=github\&logoColor=959da5\&label=Downloads\&labelColor=3f4850)](https://github.com/ww-rm/SpineViewer/releases)
[中文](README.md) | [English](README.en.md)
A *WYSIWYG* Spine file viewer & exporter.
A simple and user-friendly Spine file viewer and exporter with multi-language support (Chinese/English/Japanese).
![previewer](img/preview.webp)
## Features
- Supports multiple Spine file versions
- Drag & drop or copy/paste to open files in batch
- List-based skeleton view with render layer management
- Multi-select list to batch-adjust skeleton parameters
- Multi-track animation support
- Skin / custom slot attachment configuration
- Debug rendering mode
- Fullscreen preview
- Export to single-frame image, animated GIF/WebP/AVIF, video formats
- Batch export at multiple resolutions
- Custom FFmpeg export parameters
- …and more
* Supports multiple versions of Spine files.
* Batch open files via drag-and-drop or copy-paste.
* Batch preview functionality.
* List-based multi-skeleton viewing and render order management.
* Batch adjustment of skeleton parameters using multi-selection.
* Multi-track animation settings.
* Skin and custom slot attachment settings.
* Debug rendering support.
* Fullscreen preview mode.
* Export to single frame/image sequence/animated GIF/video formats.
* Automatic resolution batch export.
* FFmpeg custom export support.
* Program parameter saving.
* ...
### Spine Version Support
### Supported Spine Versions
| Version | View & Export | Format Conversion | Version Conversion |
| :------: | :-----------: | :---------------: | :----------------: |
| `2.1.x` | :white_check_mark: | | |
| `3.6.x` | :white_check_mark: | | |
| `3.7.x` | :white_check_mark: | | |
| `3.8.x` | :white_check_mark: | :white_check_mark: | |
| `4.0.x` | :white_check_mark: | | |
| `4.1.x` | :white_check_mark: | | |
| `4.2.x` | :white_check_mark: | :white_check_mark: | |
| `4.3.x` | | | |
| Version | View & Export |
| :-----: | :------------------: |
| `2.1.x` | :white\_check\_mark: |
| `3.6.x` | :white\_check\_mark: |
| `3.7.x` | :white\_check\_mark: |
| `3.8.x` | :white\_check\_mark: |
| `4.0.x` | :white\_check\_mark: |
| `4.1.x` | :white\_check\_mark: |
| `4.2.x` | :white\_check\_mark: |
| `4.3.x` | |
More versions coming soon 🚀🚀🚀
More versions under development \:rocket: \:rocket: \:rocket:
### Supported Export Formats
| Export Format | Use Case |
| --------------------- | ----------------------------------------------------------------------------------------- |
| Single Frame | Generate highresolution still images; pick any frame manually. |
| Frame Sequence (PNG) | Lossless PNG sequences with alpha channel preserved. |
| GIF / WebP / AVIF | Perfect for quick animated previews. |
| MP4 | The most widely compatible video format. |
| WebM | Browserfriendly streaming with optional transparency. |
| MKV / MOV | For those who like to tinker. |
| Custom FFmpeg Command | Use any FFmpeg arguments for complex, tailored export workflows. |
| Format | Use Case |
| -------------- | ----------------------------------------------------------------------------- |
| Single Frame | Generate high-resolution images of models; manually adjust the desired frame. |
| Frame Sequence | Supports PNG format with transparency and lossless compression. |
| GIF/Video | Export preview animations or common video formats. |
| Custom Export | Supports arbitrary FFmpeg parameters for custom, complex export needs. |
## Installation
1. Go to the [Releases](https://github.com/ww-rm/SpineViewer/releases) page and download the ZIP.
2. Make sure you have the [.NET Desktop Runtime 8.0.x](https://dotnet.microsoft.com/download/dotnet/8.0) installed.
3. Alternatively, download the `SelfContained` ZIP, which runs standalone without any .NET prerequisites.
4. To export GIF or other video formats, install the `ffmpeg` CLI and add it to your PATH.
- Windows builds: see the [FFmpeg download page](https://ffmpeg.org/download.html#build-windows)
- Direct download: [ffmpeg-release-full.7z](https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full.7z)
Download the compressed package from the [Release](https://github.com/ww-rm/SpineViewer/releases) page.
The software requires the [.NET Desktop Runtime 8.0.x](https://dotnet.microsoft.com/download/dotnet/8.0) to run.
Alternatively, download the package with the `SelfContained` suffix for standalone execution.
For exporting GIF/MP4 and other animation/video formats, FFmpeg must be installed and added to the system environment variables. Visit the [FFmpeg Windows download page](https://ffmpeg.org/download.html#build-windows) or download the latest version directly: [ffmpeg-release-full.7z](https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full.7z).
## Usage
### Importing Skeletons
### How to Change the Display Language
You can import Spine skeletons in three ways:
In the menu, go to "File" -> "Preferences..." -> "Language," select your desired language, and confirm the change.
- Drag & drop or paste skeleton files or folders onto the model list.
- Use **File > Open** to batchopen multiple skeleton files.
- Use **File > Open Single Model** to open one at a time.
### Basic Overview
### Adjusting Content
The program is organized into a left-right layout:
- Rightclick menu and keyboard shortcuts are available in the model list. You can multiselect to adjust parameters in batch.
- In the preview pane, you can also use mouse controls:
- **Leftclick & drag** to move a model; hold **Ctrl** to multiselect (synced with the list).
- **Rightclick & drag** to pan the entire scene.
- **Mouse wheel** to zoom; hold **Ctrl** to zoom all selected models proportionally.
- **“Render Selected Only”** mode shows only the selected models in preview; use the list to change selection.
* **Left Panel:** Functionality panel.
* **Right Panel:** Preview display.
Below the preview, playback controls let you scrub through the timeline like a basic player.
The left panel includes three sub-panels:
### Exporting Content
* **Browse:** Preview the content of a specified folder without importing files into the program. This panel allows generating `.webp` previews for models or importing selected models.
* **Model:** Lists imported models for rendering. Parameters and rendering order can be adjusted here, along with other model-related functionalities.
* **Display:** Adjust parameters for the right-side preview display.
Exports follow the “what you see is what you get” principle—your realtime preview is exactly what gets exported.
Hover your mouse over buttons, labels, or input fields to see help text for most UI elements.
Key export options:
### Skeleton Import
- **Render Selected Only**: includes only the selected models in both preview and export.
- **Output Folder**: if unspecified, exports go into each models source folder; otherwise, everything exports to the chosen folder.
- **Export Single**: by default, each model is exported separately; enable this to render all selected models together into a single output.
- **Auto Resolution**: ignores preview resolution and viewport size—exports at the contents actual bounds; for animations, matches the full animation area.
Drag-and-drop or paste skeleton files/directories into the Model panel.
## More
Alternatively, use the right-click menu in the Browse panel to import selected items.
Detailed usage and advanced tips are in the [Wiki](https://github.com/ww-rm/SpineViewer/wiki).
Encounter a bug or have a feature request? Open an [Issue](https://github.com/ww-rm/SpineViewer/issues).
### Content Adjustment
The Model panel supports right-click menus, some shortcuts, and batch adjustments of model parameters through multi-selection.
For preview display adjustments:
* **Left-click:** Select and drag models. Hold `Ctrl` for multi-selection, synchronized with the left-side list.
* **Right-click:** Drag the entire display.
* **Scroll wheel:** Zoom in/out. Hold `Ctrl` to scale selected models.
* **Render selected-only mode:** In this mode, the preview only shows selected models, and selection status can only be changed via the left-side list.
The buttons below the preview display allow time adjustments, serving as a simple playback control.
### Content Export
Export follows the **WYSIWYG (What You See Is What You Get)** principle, meaning the preview display reflects the exported output.
Use the right-click menu in the Model panel to export selected items.
Key export parameters include:
* **Output folder:** Optional. When not specified, output is saved to the respective model folder; otherwise, all output is saved to the provided folder.
* **Export single:** By default, each model is exported independently. Selecting "Export single" renders all selected models in a single frame, producing a unified output.
* **Auto resolution:** Ignores the preview resolution and viewport parameters, exporting output at the actual size of the content. For animations/videos, the output matches the size required for full visibility.
### More Information
For detailed usage and documentation, see the [Wiki](https://github.com/ww-rm/SpineViewer/wiki). For usage questions or bug reports, submit an [Issue](https://github.com/ww-rm/SpineViewer/issues).
## Acknowledgements
- [spine-runtimes](https://github.com/EsotericSoftware/spine-runtimes)
- [SFML.Net](https://github.com/SFML/SFML.Net)
- [FFMpegCore](https://github.com/rosenbjerg/FFMpegCore)
* [spine-runtimes](https://github.com/EsotericSoftware/spine-runtimes)
* [SFML.Net](https://github.com/SFML/SFML.Net)
* [FFMpegCore](https://github.com/rosenbjerg/FFMpegCore)
* [HandyControl](https://github.com/HandyOrg/HandyControl)
* [NLog](https://github.com/NLog/NLog)
* [SkiaSharp](https://github.com/mono/SkiaSharp)
---
If you find this project useful, please give it a and share it with others!
*If you find this project helpful, please give it a \:star: and share it with others! :)*
[![Stargazers over time](https://starchart.cc/ww-rm/SpineViewer.svg?variant=adaptive)](https://starchart.cc/ww-rm/SpineViewer)

View File

@@ -6,7 +6,7 @@
[中文](README.md) | [English](README.en.md)
*所见即所得* 的 Spine 文件查看&导出程序.
一个简单好用的 Spine 文件查看&导出程序, 支持中/英/日多语言界面.
![previewer](img/preview.webp)
@@ -14,6 +14,7 @@
- 支持多版本 spine 文件
- 支持拖拽/复制粘贴批量打开文件
- 支持批量预览
- 支持列表式多骨骼查看和渲染层级管理
- 支持列表多选批量设置骨骼参数
- 支持多轨道动画设置
@@ -23,20 +24,21 @@
- 支持单帧/动图/视频文件导出
- 支持自动分辨率批量导出
- 支持 FFmpeg 自定义导出
- ...
- 支持程序参数保存
- ......
### Spine 版本支持
| 版本 | 查看&导出 | 格式转换 | 版本转换 |
| :---: | :---: | :---: | :---: |
| `2.1.x` | :white_check_mark: | | |
| `3.6.x` | :white_check_mark: | | |
| `3.7.x` | :white_check_mark: | | |
| `3.8.x` | :white_check_mark: | :white_check_mark: | |
| `4.0.x` | :white_check_mark: | | |
| `4.1.x` | :white_check_mark: | | |
| `4.2.x` | :white_check_mark: | :white_check_mark: | |
| `4.3.x` | | | |
| 版本 | 查看&导出 |
| :---: | :---: |
| `2.1.x` | :white_check_mark: |
| `3.6.x` | :white_check_mark: |
| `3.7.x` | :white_check_mark: |
| `3.8.x` | :white_check_mark: |
| `4.0.x` | :white_check_mark: |
| `4.1.x` | :white_check_mark: |
| `4.2.x` | :white_check_mark: |
| `4.3.x` | |
更多版本正在施工 :rocket: :rocket: :rocket:
@@ -46,10 +48,7 @@
| --- | --- |
| 单帧画面 | 支持生成高清模型画面图像, 可手动调节需要的一帧. |
| 帧序列 | 支持 PNG 格式帧序列, 可保留透明通道且无损压缩. |
| GIF/WebP/AVIF | 适合生成预览动图. |
| MP4 | 最常见的视频格式, 兼容性最好. |
| WebM | 适合浏览器在线播放格式, 支持透明背景. |
| MKV/MOV | 适合折腾. |
| 动图/视频 | 可以生成预览动图或者常见格式视频. |
| 自定义导出 | 除上述预设方案, 支持提供任意 FFmpeg 参数进行导出, 满足自定义复杂需求. |
## 安装
@@ -60,21 +59,35 @@
也可以下载带有 `SelfContained` 后缀的压缩包, 可以独立运行.
导出 GIF视频格式需要在本地安装 ffmpeg 命令行, 并且添加至环境变量, [点击前往 FFmpeg-Windows 下载页面](https://ffmpeg.org/download.html#build-windows), 也可以点这个下载最新版本 [ffmpeg-release-full.7z](https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full.7z).
导出 GIF/MP4 等动图/视频格式需要在本地安装 ffmpeg 命令行, 并且添加至环境变量, [点击前往 FFmpeg-Windows 下载页面](https://ffmpeg.org/download.html#build-windows), 也可以点这个下载最新版本 [ffmpeg-release-full.7z](https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full.7z).
## 使用方法
### 如何修改显示语言
窗口菜单的 "文件" -> "首选项..." -> "语言", 选择你需要的语言并确认修改.
### 基本介绍
程序大致是左右布局, 左侧是功能面板, 右侧是画面.
左侧有三个子面板, 分别是:
- **浏览**. 该面板用于预览指定文件夹的内容, 并没有真正导入文件到程序. 在该面板可以为模型生成 webp 格式的预览图, 或者导入选中的模型.
- **模型**. 该面板记录导入并进行渲染的模型列表, 可以在这个面板设置与模型渲染相关的参数和渲染顺序, 以及一些与模型有关的功能.
- **画面**. 该面板用于设置右侧预览画面的参数.
绝大部分按钮或者标签或者输入框都可以通过鼠标指针悬停来获取帮助文本.
### 骨骼导入
有 3 种方式导入骨骼文件:
可以直接拖放/粘贴需要导入骨骼文件/目录到模型面板.
- 拖放/粘贴需要导入的骨骼文件/目录到模型列表
- 从文件菜单里批量打开骨骼文件
- 从文件菜单选择单个模型打开
或者在浏览面板内右键菜单导入选中项.
### 内容调整
模型列表支持右键菜单以及部分快捷键, 并且可以多选进行模型参数的批量调整.
模型面板支持右键菜单以及部分快捷键, 并且可以多选进行模型参数的批量调整.
预览画面除了使用面板进行参数设置外, 支持部分鼠标动作:
@@ -89,9 +102,10 @@
导出遵循 "所见即所得" 原则, 即实时预览的画面就是你导出的画面.
在模型面板里, 右键菜单可以对选中项进行导出操作.
导出有以下几个关键参数:
- 仅渲染选中. 这个参数不仅影响预览模式, 也影响导出, 如果仅渲染选中, 那么在导出时只有被选中的模型会被考虑, 忽略其他模型.
- 输出文件夹. 这个参数某些时候可选, 当不提供时, 则将输出产物输出到每个模型各自的模型文件夹, 否则输出产物全部输出到提供的输出文件夹.
- 导出单个. 默认是每个模型独立导出, 即对模型列表进行批量操作, 如果选择仅导出单个, 那么被导出的所有模型将在同一个画面上被渲染, 输出产物只有一份.
- 自动分辨率. 该模式会忽略预览画面的分辨率和视区参数, 导出产物的分辨率与被导出内容的实际大小一致, 如果是动图或者视频则会与完整显示动画的必需大小一致.
@@ -105,6 +119,9 @@
- [spine-runtimes](https://github.com/EsotericSoftware/spine-runtimes)
- [SFML.Net](https://github.com/SFML/SFML.Net)
- [FFMpegCore](https://github.com/rosenbjerg/FFMpegCore)
- [HandyControl](https://github.com/HandyOrg/HandyControl)
- [NLog](https://github.com/NLog/NLog)
- [SkiaSharp](https://github.com/mono/SkiaSharp)
---

View File

@@ -7,7 +7,7 @@
<TargetFramework>net8.0-windows</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>0.15.0</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

@@ -26,18 +26,37 @@ namespace Spine.Implementations.SpineWrappers.V21
private readonly ImmutableArray<IAnimation> _animations;
private readonly FrozenDictionary<string, IAnimation> _animationsByName;
public SpineObjectData21(string skelPath, string atlasPath) : base(skelPath, atlasPath)
public SpineObjectData21(string skelPath, string atlasPath, Spine.SpineWrappers.TextureLoader textureLoader)
: base(skelPath, atlasPath, textureLoader)
{
// 加载 atlas
try { _atlas = new Atlas(atlasPath, _textureLoader); }
try { _atlas = new Atlas(atlasPath, textureLoader); }
catch (Exception ex) { throw new InvalidDataException($"Failed to load atlas '{atlasPath}'", ex); }
try
{
if (Utf8Validator.IsUtf8(skelPath))
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
{
try
{
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
}
catch
{
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
}
}
else
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
{
try
{
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
}
catch
{
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
}
}
}
catch (Exception ex)
{

View File

@@ -26,18 +26,37 @@ namespace Spine.Implementations.SpineWrappers.V36
private readonly ImmutableArray<IAnimation> _animations;
private readonly FrozenDictionary<string, IAnimation> _animationsByName;
public SpineObjectData36(string skelPath, string atlasPath) : base(skelPath, atlasPath)
public SpineObjectData36(string skelPath, string atlasPath, Spine.SpineWrappers.TextureLoader textureLoader)
: base(skelPath, atlasPath, textureLoader)
{
// 加载 atlas
try { _atlas = new Atlas(atlasPath, _textureLoader); }
try { _atlas = new Atlas(atlasPath, textureLoader); }
catch (Exception ex) { throw new InvalidDataException($"Failed to load atlas '{atlasPath}'", ex); }
try
{
if (Utf8Validator.IsUtf8(skelPath))
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
{
try
{
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
}
catch
{
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
}
}
else
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
{
try
{
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
}
catch
{
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
}
}
}
catch (Exception ex)
{

View File

@@ -26,18 +26,37 @@ namespace Spine.Implementations.SpineWrappers.V37
private readonly ImmutableArray<IAnimation> _animations;
private readonly FrozenDictionary<string, IAnimation> _animationsByName;
public SpineObjectData37(string skelPath, string atlasPath) : base(skelPath, atlasPath)
public SpineObjectData37(string skelPath, string atlasPath, Spine.SpineWrappers.TextureLoader textureLoader)
: base(skelPath, atlasPath, textureLoader)
{
// 加载 atlas
try { _atlas = new Atlas(atlasPath, _textureLoader); }
try { _atlas = new Atlas(atlasPath, textureLoader); }
catch (Exception ex) { throw new InvalidDataException($"Failed to load atlas '{atlasPath}'", ex); }
try
{
if (Utf8Validator.IsUtf8(skelPath))
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
{
try
{
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
}
catch
{
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
}
}
else
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
{
try
{
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
}
catch
{
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
}
}
}
catch (Exception ex)
{

View File

@@ -27,18 +27,37 @@ namespace Spine.Implementations.SpineWrappers.V38
private readonly ImmutableArray<IAnimation> _animations;
private readonly FrozenDictionary<string, IAnimation> _animationsByName;
public SpineObjectData38(string skelPath, string atlasPath) : base(skelPath, atlasPath)
public SpineObjectData38(string skelPath, string atlasPath, Spine.SpineWrappers.TextureLoader textureLoader)
: base(skelPath, atlasPath, textureLoader)
{
// 加载 atlas
try { _atlas = new Atlas(atlasPath, _textureLoader); }
try { _atlas = new Atlas(atlasPath, textureLoader); }
catch (Exception ex) { throw new InvalidDataException($"Failed to load atlas '{atlasPath}'", ex); }
try
{
if (Utf8Validator.IsUtf8(skelPath))
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
{
try
{
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
}
catch
{
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
}
}
else
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
{
try
{
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
}
catch
{
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
}
}
}
catch (Exception ex)
{

View File

@@ -26,19 +26,38 @@ namespace Spine.Implementations.SpineWrappers.V40
private readonly ImmutableArray<IAnimation> _animations;
private readonly FrozenDictionary<string, IAnimation> _animationsByName;
public SpineObjectData40(string skelPath, string atlasPath) : base(skelPath, atlasPath)
public SpineObjectData40(string skelPath, string atlasPath, Spine.SpineWrappers.TextureLoader textureLoader)
: base(skelPath, atlasPath, textureLoader)
{
// 加载 atlas
try { _atlas = new Atlas(atlasPath, _textureLoader); }
try { _atlas = new Atlas(atlasPath, textureLoader); }
catch (Exception ex) { throw new InvalidDataException($"Failed to load atlas '{atlasPath}'", ex); }
// 加载 skel
try
{
if (Utf8Validator.IsUtf8(skelPath))
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
{
try
{
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
}
catch
{
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
}
}
else
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
{
try
{
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
}
catch
{
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
}
}
}
catch (Exception ex)
{

View File

@@ -26,19 +26,38 @@ namespace Spine.Implementations.SpineWrappers.V41
private readonly ImmutableArray<IAnimation> _animations;
private readonly FrozenDictionary<string, IAnimation> _animationsByName;
public SpineObjectData41(string skelPath, string atlasPath) : base(skelPath, atlasPath)
public SpineObjectData41(string skelPath, string atlasPath, Spine.SpineWrappers.TextureLoader textureLoader)
: base(skelPath, atlasPath, textureLoader)
{
// 加载 atlas
try { _atlas = new Atlas(atlasPath, _textureLoader); }
try { _atlas = new Atlas(atlasPath, textureLoader); }
catch (Exception ex) { throw new InvalidDataException($"Failed to load atlas '{atlasPath}'", ex); }
// 加载 skel
try
{
if (Utf8Validator.IsUtf8(skelPath))
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
{
try
{
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
}
catch
{
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
}
}
else
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
{
try
{
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
}
catch
{
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
}
}
}
catch (Exception ex)
{

View File

@@ -26,19 +26,38 @@ namespace Spine.Implementations.SpineWrappers.V42
private readonly ImmutableArray<IAnimation> _animations;
private readonly FrozenDictionary<string, IAnimation> _animationsByName;
public SpineObjectData42(string skelPath, string atlasPath) : base(skelPath, atlasPath)
public SpineObjectData42(string skelPath, string atlasPath, Spine.SpineWrappers.TextureLoader textureLoader)
: base(skelPath, atlasPath, textureLoader)
{
// 加载 atlas
try { _atlas = new Atlas(atlasPath, _textureLoader); }
try { _atlas = new Atlas(atlasPath, textureLoader); }
catch (Exception ex) { throw new InvalidDataException($"Failed to load atlas '{atlasPath}'", ex); }
// 加载 skel
try
{
if (Utf8Validator.IsUtf8(skelPath))
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
{
try
{
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
}
catch
{
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
}
}
else
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
{
try
{
_skeletonData = new SkeletonBinary(_atlas).ReadSkeletonData(skelPath);
}
catch
{
_skeletonData = new SkeletonJson(_atlas).ReadSkeletonData(skelPath);
}
}
}
catch (Exception ex)
{

View File

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

View File

@@ -20,8 +20,8 @@ namespace Spine
public static readonly FrozenDictionary<string, string> PossibleSuffixMapping = new Dictionary<string, string>()
{
[".skel"] = ".atlas",
[".skel.bytes"] = ".atlas.txt",
[".json"] = ".atlas",
[".skel.bytes"] = ".atlas.bytes",
}.ToFrozenDictionary();
/// <summary>
@@ -45,10 +45,12 @@ namespace Spine
/// <param name="skelPath">skel 文件路径</param>
/// <param name="atlasPath">atlas 文件路径, 为空时会根据 <paramref name="skelPath"/> 进行自动检测</param>
/// <param name="version">要使用的运行时版本, 为空时会自动检测</param>
public SpineObject(string skelPath, string? atlasPath = null, SpineVersion? version = null)
public SpineObject(string skelPath, string? atlasPath = null, SpineVersion? version = null, TextureLoader? textureLoader = null)
{
if (string.IsNullOrWhiteSpace(skelPath)) throw new ArgumentException(skelPath, nameof(skelPath));
if (!File.Exists(skelPath)) throw new FileNotFoundException($"{nameof(skelPath)} not found", skelPath);
textureLoader ??= TextureLoader.DefaultLoader;
SkelPath = Path.GetFullPath(skelPath);
AssetsDir = Directory.GetParent(skelPath).FullName;
Name = Path.GetFileNameWithoutExtension(skelPath);
@@ -91,7 +93,7 @@ namespace Spine
{
try
{
_data = SpineObjectData.New(v, skelPath, atlasPath);
_data = SpineObjectData.New(v, skelPath, atlasPath, textureLoader);
Version = v;
break;
}
@@ -109,7 +111,7 @@ namespace Spine
{
// 根据版本实例化对象
Version = version;
_data = SpineObjectData.New(Version, skelPath, atlasPath);
_data = SpineObjectData.New(Version, skelPath, atlasPath, textureLoader);
}
// 创建状态实例
@@ -177,7 +179,6 @@ namespace Spine
// 拷贝调试属性
EnableDebug = other.EnableDebug;
DebugTexture = other.DebugTexture;
DebugNonTexture = other.DebugNonTexture;
DebugBounds = other.DebugBounds;
DebugBones = other.DebugBones;
DebugRegions = other.DebugRegions;
@@ -236,7 +237,7 @@ namespace Spine
/// <summary>
/// 是否使用预乘 Alpha
/// </summary>
public bool UsePma { get; set; } = false;
public bool UsePma { get; set; }
/// <summary>
/// 物理约束更新方式
@@ -246,62 +247,57 @@ namespace Spine
/// <summary>
/// 启用渲染调试, 将会使所有 <c>DebugXXX</c> 属性生效
/// </summary>
public bool EnableDebug { get; set; } = false;
public bool EnableDebug { get; set; }
/// <summary>
/// 显示纹理
/// </summary>
public bool DebugTexture { get; set; } = true;
/// <summary>
/// 是否显示非纹理内容, 一个总开关
/// </summary>
public bool DebugNonTexture { get; set; } = true;
/// <summary>
/// 显示包围盒
/// </summary>
public bool DebugBounds { get; set; } = true;
public bool DebugBounds { get; set; }
/// <summary>
/// 显示骨骼
/// </summary>
public bool DebugBones { get; set; } = false;
public bool DebugBones { get; set; }
/// <summary>
/// 显示区域附件边框
/// </summary>
public bool DebugRegions { get; set; } = false;
public bool DebugRegions { get; set; }
/// <summary>
/// 显示网格附件边框线
/// </summary>
public bool DebugMeshHulls { get; set; } = false;
public bool DebugMeshHulls { get; set; }
/// <summary>
/// 显示网格附件网格线
/// </summary>
public bool DebugMeshes { get; set; } = false;
public bool DebugMeshes { get; set; }
/// <summary>
/// 显示碰撞盒附件边框线
/// </summary>
public bool DebugBoundingBoxes { get; set; } = false;
public bool DebugBoundingBoxes { get; set; }
/// <summary>
/// 显示路径附件网格线
/// </summary>
public bool DebugPaths { get; set; } = false;
public bool DebugPaths { get; set; }
/// <summary>
/// 显示点附件
/// </summary>
public bool DebugPoints { get; set; } = false;
public bool DebugPoints { get; set; }
/// <summary>
/// 显示剪裁附件网格线
/// </summary>
public bool DebugClippings { get; set; } = false;
public bool DebugClippings { get; set; }
/// <summary>
/// 获取某个插槽上的附件名, 插槽不存在或者无附件均返回 null
@@ -482,7 +478,6 @@ namespace Spine
}
var attachment = slot.Attachment;
SFML.Graphics.Texture texture;
float[] worldVertices; // 顶点世界坐标数组, 连续的 [x0, y0, x1, y1, ...] 坐标值
int worldVerticesLength; // 顶点数组的长度
@@ -495,10 +490,11 @@ namespace Spine
float tintB = _skeleton.B * slot.B;
float tintA = _skeleton.A * slot.A;
SFML.Graphics.Texture texture;
switch (attachment)
{
case IRegionAttachment regionAttachment:
texture = regionAttachment.RendererObject;
worldVerticesLength = regionAttachment.ComputeWorldVertices(slot, ref _worldVertices);
worldVertices = _worldVertices;
triangles = regionAttachment.Triangles;
@@ -508,9 +504,11 @@ namespace Spine
tintG *= regionAttachment.G;
tintB *= regionAttachment.B;
tintA *= regionAttachment.A;
// NOTE: RenderObject 的获取要在 ComputeWorldVertices 发生之后, 否则可能存在某些 Region 尚未被赋值产生 null 引用报错
texture = regionAttachment.RendererObject;
break;
case IMeshAttachment meshAttachment:
texture = meshAttachment.RendererObject;
worldVerticesLength = meshAttachment.ComputeWorldVertices(slot, ref _worldVertices);
worldVertices = _worldVertices;
triangles = meshAttachment.Triangles;
@@ -520,9 +518,9 @@ namespace Spine
tintG *= meshAttachment.G;
tintB *= meshAttachment.B;
tintA *= meshAttachment.A;
texture = meshAttachment.RendererObject;
break;
case ISkinnedMeshAttachment skinnedMeshAttachment:
texture = skinnedMeshAttachment.RendererObject;
worldVerticesLength = skinnedMeshAttachment.ComputeWorldVertices(slot, ref _worldVertices);
worldVertices = _worldVertices;
triangles = skinnedMeshAttachment.Triangles;
@@ -532,6 +530,7 @@ namespace Spine
tintG *= skinnedMeshAttachment.G;
tintB *= skinnedMeshAttachment.B;
tintA *= skinnedMeshAttachment.A;
texture = skinnedMeshAttachment.RendererObject;
break;
case IClippingAttachment clippingAttachment:
_clipping.ClipStart(slot, clippingAttachment);
@@ -864,7 +863,7 @@ namespace Spine
else
{
if (DebugTexture) DrawTexture(target, states);
if (DebugNonTexture) DrawNonTexture(target);
DrawNonTexture(target);
}
}

View File

@@ -20,18 +20,13 @@ namespace Spine.SpineWrappers
/// <summary>
/// 构建版本对象
/// </summary>
public static SpineObjectData New(SpineVersion version, string skelPath, string atlasPath) => CreateInstance(version.Tag, skelPath, atlasPath);
/// <summary>
/// 纹理加载器, 可以设置一些预置参数
/// </summary>
public static TextureLoader TextureLoader => _textureLoader;
protected static readonly TextureLoader _textureLoader = new();
public static SpineObjectData New(SpineVersion version, string skelPath, string atlasPath, TextureLoader textureLoader)
=> CreateInstance(version.Tag, skelPath, atlasPath, textureLoader);
/// <summary>
/// 构造函数, 继承的子类应当实现一个相同签名的构造函数
/// </summary>
public SpineObjectData(string skelPath, string atlasPath) { }
public SpineObjectData(string skelPath, string atlasPath, TextureLoader textureLoader) { }
public abstract string SkeletonVersion { get; }

View File

@@ -19,23 +19,62 @@ namespace Spine.SpineWrappers
SpineRuntime42.TextureLoader
{
/// <summary>
/// 强制启用 Smooth
/// 默认的全局纹理加载器
/// </summary>
public bool ForceSmooth { get; set; } = false;
public static TextureLoader DefaultLoader { get; } = new();
/// <summary>
/// 强制启用 Repeated
/// 在读取纹理时强制进行通道预乘操作
/// </summary>
public bool ForceRepeated { get; set; } = false;
public bool ForcePremul { get; set; }
/// <summary>
/// 强制使用 Nearest
/// </summary>
public bool ForceNearest { get; set; }
/// <summary>
/// 强制启用 Mipmap
/// </summary>
public bool ForceMipmap { get; set; } = false;
public bool ForceMipmap { get; set; }
public void Load(SpineRuntime21.AtlasPage page, string path)
private SFML.Graphics.Texture ReadTexture(string path)
{
var texture = new SFML.Graphics.Texture(path);
if (ForcePremul)
{
using var image = new SFML.Graphics.Image(path);
var width = image.Size.X;
var height = image.Size.Y;
var pixels = image.Pixels;
var size = width * height * 4;
for (int i = 0; i < size; i += 4)
{
byte a = pixels[i + 3];
if (a == 0)
{
pixels[i + 0] = 0;
pixels[i + 1] = 0;
pixels[i + 2] = 0;
}
else if (a != 255)
{
float f = a / 255f;
pixels[i + 0] = (byte)(pixels[i + 0] * f);
pixels[i + 1] = (byte)(pixels[i + 1] * f);
pixels[i + 2] = (byte)(pixels[i + 2] * f);
}
}
var tex = new SFML.Graphics.Texture(width, height);
tex.Update(pixels);
return tex;
}
return new(path);
}
public virtual void Load(SpineRuntime21.AtlasPage page, string path)
{
var texture = ReadTexture(path);
if (page.magFilter == SpineRuntime21.TextureFilter.Linear)
{
texture.Smooth = true;
@@ -61,16 +100,16 @@ namespace Spine.SpineWrappers
break;
}
if (ForceSmooth) texture.Smooth = true;
if (ForceRepeated) texture.Repeated = true;
if (ForceNearest) texture.Smooth = false;
if (ForceMipmap) texture.GenerateMipmap();
page.rendererObject = texture;
}
public void Load(SpineRuntime36.AtlasPage page, string path)
public virtual void Load(SpineRuntime36.AtlasPage page, string path)
{
var texture = new SFML.Graphics.Texture(path);
var texture = ReadTexture(path);
if (page.magFilter == SpineRuntime36.TextureFilter.Linear)
{
texture.Smooth = true;
@@ -96,16 +135,16 @@ namespace Spine.SpineWrappers
break;
}
if (ForceSmooth) texture.Smooth = true;
if (ForceRepeated) texture.Repeated = true;
if (ForceNearest) texture.Smooth = false;
if (ForceMipmap) texture.GenerateMipmap();
page.rendererObject = texture;
}
public void Load(SpineRuntime37.AtlasPage page, string path)
public virtual void Load(SpineRuntime37.AtlasPage page, string path)
{
var texture = new SFML.Graphics.Texture(path);
var texture = ReadTexture(path);
if (page.magFilter == SpineRuntime37.TextureFilter.Linear)
{
texture.Smooth = true;
@@ -131,16 +170,16 @@ namespace Spine.SpineWrappers
break;
}
if (ForceSmooth) texture.Smooth = true;
if (ForceRepeated) texture.Repeated = true;
if (ForceNearest) texture.Smooth = false;
if (ForceMipmap) texture.GenerateMipmap();
page.rendererObject = texture;
}
public void Load(SpineRuntime38.AtlasPage page, string path)
public virtual void Load(SpineRuntime38.AtlasPage page, string path)
{
var texture = new SFML.Graphics.Texture(path);
var texture = ReadTexture(path);
if (page.magFilter == SpineRuntime38.TextureFilter.Linear)
{
texture.Smooth = true;
@@ -166,16 +205,20 @@ namespace Spine.SpineWrappers
break;
}
if (ForceSmooth) texture.Smooth = true;
if (ForceRepeated) texture.Repeated = true;
if (ForceNearest) texture.Smooth = false;
if (ForceMipmap) texture.GenerateMipmap();
page.rendererObject = texture;
// 似乎是不需要设置的, 因为存在某些 png 和 atlas 大小不同的情况, 一般是有一些缩放, 如果设置了反而渲染异常
// page.width = (int)texture.Size.X;
// page.height = (int)texture.Size.Y;
}
public void Load(SpineRuntime40.AtlasPage page, string path)
public virtual void Load(SpineRuntime40.AtlasPage page, string path)
{
var texture = new SFML.Graphics.Texture(path);
var texture = ReadTexture(path);
if (page.magFilter == SpineRuntime40.TextureFilter.Linear)
{
texture.Smooth = true;
@@ -201,16 +244,16 @@ namespace Spine.SpineWrappers
break;
}
if (ForceSmooth) texture.Smooth = true;
if (ForceRepeated) texture.Repeated = true;
if (ForceNearest) texture.Smooth = false;
if (ForceMipmap) texture.GenerateMipmap();
page.rendererObject = texture;
}
public void Load(SpineRuntime41.AtlasPage page, string path)
public virtual void Load(SpineRuntime41.AtlasPage page, string path)
{
var texture = new SFML.Graphics.Texture(path);
var texture = ReadTexture(path);
if (page.magFilter == SpineRuntime41.TextureFilter.Linear)
{
texture.Smooth = true;
@@ -236,16 +279,16 @@ namespace Spine.SpineWrappers
break;
}
if (ForceSmooth) texture.Smooth = true;
if (ForceRepeated) texture.Repeated = true;
if (ForceNearest) texture.Smooth = false;
if (ForceMipmap) texture.GenerateMipmap();
page.rendererObject = texture;
}
public void Load(SpineRuntime42.AtlasPage page, string path)
public virtual void Load(SpineRuntime42.AtlasPage page, string path)
{
var texture = new SFML.Graphics.Texture(path);
var texture = ReadTexture(path);
if (page.magFilter == SpineRuntime42.TextureFilter.Linear)
{
texture.Smooth = true;
@@ -271,18 +314,13 @@ namespace Spine.SpineWrappers
break;
}
if (ForceSmooth) texture.Smooth = true;
if (ForceRepeated) texture.Repeated = true;
if (ForceNearest) texture.Smooth = false;
if (ForceMipmap) texture.GenerateMipmap();
page.rendererObject = texture;
// 似乎是不需要设置的, 因为存在某些 png 和 atlas 大小不同的情况, 一般是有一些缩放, 如果设置了反而渲染异常
// page.width = (int)texture.Size.X;
// page.height = (int)texture.Size.Y;
}
public void Unload(object texture)
public virtual void Unload(object texture)
{
((SFML.Graphics.Texture)texture).Dispose();
}

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

@@ -11,7 +11,7 @@
<ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/SkinDefault.xaml"/>
<ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/Theme.xaml"/>
<ResourceDictionary Source="/Resources/Geometries.xaml"/>
<ResourceDictionary Source="/Resources/Strings/zh-cn.xaml"/>
<ResourceDictionary Source="/Resources/Strings/zh.xaml"/>
</ResourceDictionary.MergedDictionaries>
<Style x:Key="MyToggleButton" TargetType="{x:Type ToggleButton}" BasedOn="{StaticResource ToggleButtonSwitch}">

View File

@@ -1,5 +1,6 @@
using NLog;
using SpineViewer.Views;
using System.Collections.Frozen;
using System.Configuration;
using System.Data;
using System.Diagnostics;
@@ -7,86 +8,108 @@ using System.Globalization;
using System.Reflection;
using System.Windows;
namespace SpineViewer;
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
namespace SpineViewer
{
public static string Version => Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
private static readonly Logger _logger;
static App()
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
InitializeLogConfiguration();
_logger = LogManager.GetCurrentClassLogger();
_logger.Info("Application Started");
public static string Version => Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
private static readonly Logger _logger;
static App()
{
_logger.Fatal("Unhandled exception: {0}", e.ExceptionObject);
};
TaskScheduler.UnobservedTaskException += (s, e) =>
InitializeLogConfiguration();
_logger = LogManager.GetCurrentClassLogger();
_logger.Info("Application Started");
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
{
_logger.Fatal("Unhandled exception: {0}", e.ExceptionObject);
};
TaskScheduler.UnobservedTaskException += (s, e) =>
{
_logger.Trace(e.Exception.ToString());
_logger.Error("Unobserved task exception: {0}", e.Exception.Message);
e.SetObserved();
};
}
private static void InitializeLogConfiguration()
{
var config = new NLog.Config.LoggingConfiguration();
// 文件日志
var fileTarget = new NLog.Targets.FileTarget("fileTarget")
{
Encoding = System.Text.Encoding.UTF8,
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} - ${callsite-filename:includeSourcePath=false}:${callsite-linenumber} - ${message}"
};
config.AddTarget(fileTarget);
config.AddRule(LogLevel.Trace, LogLevel.Fatal, fileTarget);
LogManager.Configuration = config;
}
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var dict = new ResourceDictionary();
var uiCulture = CultureInfo.CurrentUICulture.Name.ToLowerInvariant();
_logger.Info("Current UI Culture: {0}", uiCulture);
if (uiCulture.StartsWith("zh")) { } // 默认就是中文, 无需操作
else if (uiCulture.StartsWith("ja")) Language = AppLanguage.JA;
else Language = AppLanguage.EN;
Resources.MergedDictionaries.Add(dict);
}
private void App_DispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
{
_logger.Trace(e.Exception.ToString());
_logger.Error("Unobserved task exception: {0}", e.Exception.Message);
e.SetObserved();
};
}
private static void InitializeLogConfiguration()
{
var config = new NLog.Config.LoggingConfiguration();
// 文件日志
var fileTarget = new NLog.Targets.FileTarget("fileTarget")
{
Encoding = System.Text.Encoding.UTF8,
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} - ${callsite-filename:includeSourcePath=false}:${callsite-linenumber} - ${message}"
};
config.AddTarget(fileTarget);
config.AddRule(LogLevel.Trace, LogLevel.Fatal, fileTarget);
LogManager.Configuration = config;
}
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var dict = new ResourceDictionary();
var uiCulture = CultureInfo.CurrentUICulture.Name.ToLowerInvariant();
_logger.Info("Current UI Culture: {0}", uiCulture);
if (uiCulture.StartsWith("zh"))
{
; // 默认就是中文, 无需操作
}
else if (uiCulture.StartsWith("ja"))
{
dict.Source = new("Resources/Strings/ja-jp.xaml", UriKind.Relative);
}
else
{
dict.Source = new("Resources/Strings/en-us.xaml", UriKind.Relative);
_logger.Error("Dispatcher unhandled exception: {0}", e.Exception.Message);
e.Handled = true;
}
Resources.MergedDictionaries.Add(dict);
/// <summary>
/// 程序语言
/// </summary>
public AppLanguage Language
{
get => _language;
set
{
var uri = $"Resources/Strings/{value.ToString().ToLower()}.xaml";
try
{
Resources.MergedDictionaries.Add(new() { Source = new(uri, UriKind.Relative) });
_language = value;
}
catch (Exception ex)
{
_logger.Error("Failed to switch language to {0}, {1}", value, ex.Message);
_logger.Trace(ex.ToString());
}
}
}
private AppLanguage _language = AppLanguage.ZH;
}
private void App_DispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
public enum AppLanguage
{
_logger.Trace(e.Exception.ToString());
_logger.Error("Dispatcher unhandled exception: {0}", e.Exception.Message);
e.Handled = true;
ZH,
EN,
JA
}
}
}

View File

@@ -49,7 +49,7 @@ namespace SpineViewer.Extensions
public static Rect GetCurrentBounds(this SpineObject self)
{
self.Skeleton.GetBounds(out var x, out var y, out var w, out var h);
return new(x, y, w, h);
return new(x, y, Math.Max(w, 1e-6f), Math.Max(h, 1e-6f));
}
/// <summary>

View File

@@ -0,0 +1,84 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Spine.SpineWrappers;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading.Tasks;
using System.Windows.Media;
namespace SpineViewer.Models
{
/// <summary>
/// 首选项参数模型, 用于对话框修改以及本地保存
/// </summary>
public partial class PreferenceModel : ObservableObject
{
#region
[ObservableProperty]
private bool _forcePremul;
[ObservableProperty]
private bool _forceNearest;
[ObservableProperty]
private bool _forceMipmap;
#endregion
#region
[ObservableProperty]
private bool _isShown = true;
[ObservableProperty]
private bool _usePma;
[ObservableProperty]
private bool _debugTexture = true;
[ObservableProperty]
private bool _debugBounds;
[ObservableProperty]
private bool _debugBones;
[ObservableProperty]
private bool _debugRegions;
[ObservableProperty]
private bool _debugMeshHulls;
[ObservableProperty]
private bool _debugMeshes;
[ObservableProperty]
private bool _debugBoundingBoxes;
[ObservableProperty]
private bool _debugPaths;
[ObservableProperty]
private bool _debugPoints;
[ObservableProperty]
private bool _debugClippings;
#endregion
#region
[ObservableProperty]
private bool _renderSelectedOnly;
[ObservableProperty]
private AppLanguage _appLanguage;
#endregion
}
}

View File

@@ -13,38 +13,6 @@ namespace SpineViewer.Models
{
public class SpineObjectConfigModel
{
/// <summary>
/// 保存 Json 文件的格式参数
/// </summary>
private static readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip
};
/// <summary>
/// 从文件反序列对象, 可能抛出异常
/// </summary>
public static SpineObjectConfigModel Deserialize(string path)
{
if (!File.Exists(path)) throw new FileNotFoundException("Config file not found", path);
var json = File.ReadAllText(path, Encoding.UTF8);
var model = JsonSerializer.Deserialize<SpineObjectConfigModel>(json, _jsonOptions);
return model ?? throw new JsonException($"null data in file '{path}'");
}
/// <summary>
/// 保存预设至文件, 概率抛出异常
/// </summary>
public void Serialize(string path)
{
Directory.CreateDirectory(Path.GetDirectoryName(path));
var json = JsonSerializer.Serialize(this, _jsonOptions);
File.WriteAllText(path, json, Encoding.UTF8);
}
public bool UsePma { get; set; }
public string Physics { get; set; } = ISkeleton.Physics.Update.ToString();
@@ -67,7 +35,7 @@ namespace SpineViewer.Models
public bool DebugTexture { get; set; } = true;
public bool DebugBounds { get; set; } = true;
public bool DebugBounds { get; set; }
public bool DebugBones { get; set; }

View File

@@ -25,6 +25,12 @@ namespace SpineViewer.Models
/// </summary>
public class SpineObjectModel : ObservableObject, SFML.Graphics.Drawable, IDisposable
{
/// <summary>
/// 一些加载默认选项
/// </summary>
public static SpineObjectLoadOptions LoadOptions => _loadOptions;
private static readonly SpineObjectLoadOptions _loadOptions = new();
/// <summary>
/// 日志器
/// </summary>
@@ -40,9 +46,21 @@ namespace SpineViewer.Models
/// <summary>
/// 构造函数, 可能会抛出异常
/// </summary>
public SpineObjectModel(string skelPath, string? atlasPath = null, SpineVersion? version = null)
public SpineObjectModel(string skelPath, string? atlasPath = null)
{
_spineObject = new(skelPath, atlasPath, version) { DebugNonTexture = false };
_spineObject = new(skelPath, atlasPath)
{
UsePma = _loadOptions.UsePma,
DebugTexture = _loadOptions.DebugTexture,
DebugBounds = _loadOptions.DebugBounds,
DebugRegions = _loadOptions.DebugRegions,
DebugMeshHulls = _loadOptions.DebugMeshHulls,
DebugMeshes = _loadOptions.DebugMeshes,
DebugBoundingBoxes = _loadOptions.DebugBoundingBoxes,
DebugPaths = _loadOptions.DebugPaths,
DebugPoints = _loadOptions.DebugPoints,
DebugClippings = _loadOptions.DebugClippings
};
_skins = _spineObject.Data.Skins.Select(v => v.Name).ToImmutableArray();
_slotAttachments = _spineObject.Data.SlotAttachments.ToFrozenDictionary(it => it.Key, it => it.Value.Keys);
_animations = _spineObject.Data.Animations.Select(v => v.Name).ToImmutableArray();
@@ -52,6 +70,19 @@ namespace SpineViewer.Models
_spineObject.AnimationState.SetAnimation(0, _spineObject.Data.Animations[0], true);
}
/// <summary>
/// 从工作区配置进行构造
/// </summary>
public SpineObjectModel(SpineObjectWorkspaceConfigModel cfg)
{
_spineObject = new(cfg.SkelPath, cfg.AtlasPath);
_skins = _spineObject.Data.Skins.Select(v => v.Name).ToImmutableArray();
_slotAttachments = _spineObject.Data.SlotAttachments.ToFrozenDictionary(it => it.Key, it => it.Value.Keys);
_animations = _spineObject.Data.Animations.Select(v => v.Name).ToImmutableArray();
ObjectConfig = cfg.ObjectConfig;
_isShown = cfg.IsShown;
}
public event EventHandler<SkinStatusChangedEventArgs>? SkinStatusChanged;
public event EventHandler<SlotAttachmentChangedEventArgs>? SlotAttachmentChanged;
@@ -73,7 +104,7 @@ namespace SpineViewer.Models
public bool IsSelected
{
get { lock (_lock) return _isSelected; }
set { lock (_lock) if (SetProperty(ref _isSelected, value)) _spineObject.DebugNonTexture = _isSelected; }
set { lock (_lock) SetProperty(ref _isSelected, value); }
}
private bool _isSelected = false;
@@ -82,18 +113,18 @@ namespace SpineViewer.Models
get { lock (_lock) return _isShown; }
set { lock (_lock) SetProperty(ref _isShown, value); }
}
private bool _isShown = true;
private bool _isShown = _loadOptions.IsShown;
public bool UsePma
{
get { lock (_lock) return _spineObject.UsePma; }
set { lock (_lock) SetProperty(_spineObject.UsePma, value, _spineObject, (m, v) => m.UsePma = v); }
set { lock (_lock) SetProperty(_spineObject.UsePma, value, v => _spineObject.UsePma = v); }
}
public ISkeleton.Physics Physics
{
get { lock (_lock) return _spineObject.Physics; }
set { lock (_lock) SetProperty(_spineObject.Physics, value, _spineObject, (m, v) => m.Physics = v); }
set { lock (_lock) SetProperty(_spineObject.Physics, value, v => _spineObject.Physics = v); }
}
/// <summary>
@@ -128,7 +159,7 @@ namespace SpineViewer.Models
public bool FlipX
{
get { lock (_lock) return _spineObject.Skeleton.ScaleX < 0; }
set { lock (_lock) SetProperty(_spineObject.Skeleton.ScaleX < 0, value, _spineObject, (m, v) => m.Skeleton.ScaleX *= -1); }
set { lock (_lock) SetProperty(_spineObject.Skeleton.ScaleX < 0, value, v => _spineObject.Skeleton.ScaleX *= -1); }
}
/// <summary>
@@ -137,19 +168,19 @@ namespace SpineViewer.Models
public bool FlipY
{
get { lock (_lock) return _spineObject.Skeleton.ScaleY < 0; }
set { lock (_lock) SetProperty(_spineObject.Skeleton.ScaleY < 0, value, _spineObject, (m, v) => m.Skeleton.ScaleY *= -1); }
set { lock (_lock) SetProperty(_spineObject.Skeleton.ScaleY < 0, value, v => _spineObject.Skeleton.ScaleY *= -1); }
}
public float X
{
get { lock (_lock) return _spineObject.Skeleton.X; }
set { lock (_lock) SetProperty(_spineObject.Skeleton.X, value, _spineObject, (m, v) => m.Skeleton.X = v); }
set { lock (_lock) SetProperty(_spineObject.Skeleton.X, value, v => _spineObject.Skeleton.X = v); }
}
public float Y
{
get { lock (_lock) return _spineObject.Skeleton.Y; }
set { lock (_lock) SetProperty(_spineObject.Skeleton.Y, value, _spineObject, (m, v) => m.Skeleton.Y = v); }
set { lock (_lock) SetProperty(_spineObject.Skeleton.Y, value, v => _spineObject.Skeleton.Y = v); }
}
public ImmutableArray<string> Skins => _skins;
@@ -242,67 +273,67 @@ namespace SpineViewer.Models
public bool EnableDebug
{
get { lock (_lock) return _spineObject.EnableDebug; }
set { lock (_lock) SetProperty(_spineObject.EnableDebug, value, _spineObject, (m, v) => m.EnableDebug = v); }
set { lock (_lock) SetProperty(_spineObject.EnableDebug, value, v => _spineObject.EnableDebug = v); }
}
public bool DebugTexture
{
get { lock (_lock) return _spineObject.DebugTexture; }
set { lock (_lock) SetProperty(_spineObject.DebugTexture, value, _spineObject, (m, v) => m.DebugTexture = v); }
set { lock (_lock) SetProperty(_spineObject.DebugTexture, value, v => _spineObject.DebugTexture = v); }
}
public bool DebugBounds
{
get { lock (_lock) return _spineObject.DebugBounds; }
set { lock (_lock) SetProperty(_spineObject.DebugBounds, value, _spineObject, (m, v) => m.DebugBounds = v); }
set { lock (_lock) SetProperty(_spineObject.DebugBounds, value, v => _spineObject.DebugBounds = v); }
}
public bool DebugBones
{
get { lock (_lock) return _spineObject.DebugBones; }
set { lock (_lock) SetProperty(_spineObject.DebugBones, value, _spineObject, (m, v) => m.DebugBones = v); }
set { lock (_lock) SetProperty(_spineObject.DebugBones, value, v => _spineObject.DebugBones = v); }
}
public bool DebugRegions
{
get { lock (_lock) return _spineObject.DebugRegions; }
set { lock (_lock) SetProperty(_spineObject.DebugRegions, value, _spineObject, (m, v) => m.DebugRegions = v); }
set { lock (_lock) SetProperty(_spineObject.DebugRegions, value, v => _spineObject.DebugRegions = v); }
}
public bool DebugMeshHulls
{
get { lock (_lock) return _spineObject.DebugMeshHulls; }
set { lock (_lock) SetProperty(_spineObject.DebugMeshHulls, value, _spineObject, (m, v) => m.DebugMeshHulls = v); }
set { lock (_lock) SetProperty(_spineObject.DebugMeshHulls, value, v => _spineObject.DebugMeshHulls = v); }
}
public bool DebugMeshes
{
get { lock (_lock) return _spineObject.DebugMeshes; }
set { lock (_lock) SetProperty(_spineObject.DebugMeshes, value, _spineObject, (m, v) => m.DebugMeshes = v); }
set { lock (_lock) SetProperty(_spineObject.DebugMeshes, value, v => _spineObject.DebugMeshes = v); }
}
public bool DebugBoundingBoxes
{
get { lock (_lock) return _spineObject.DebugBoundingBoxes; }
set { lock (_lock) SetProperty(_spineObject.DebugBoundingBoxes, value, _spineObject, (m, v) => m.DebugBoundingBoxes = v); }
set { lock (_lock) SetProperty(_spineObject.DebugBoundingBoxes, value, v => _spineObject.DebugBoundingBoxes = v); }
}
public bool DebugPaths
{
get { lock (_lock) return _spineObject.DebugPaths; }
set { lock (_lock) SetProperty(_spineObject.DebugPaths, value, _spineObject, (m, v) => m.DebugPaths = v); }
set { lock (_lock) SetProperty(_spineObject.DebugPaths, value, v => _spineObject.DebugPaths = v); }
}
public bool DebugPoints
{
get { lock (_lock) return _spineObject.DebugPoints; }
set { lock (_lock) SetProperty(_spineObject.DebugPoints, value, _spineObject, (m, v) => m.DebugPoints = v); }
set { lock (_lock) SetProperty(_spineObject.DebugPoints, value, v => _spineObject.DebugPoints = v); }
}
public bool DebugClippings
{
get { lock (_lock) return _spineObject.DebugClippings; }
set { lock (_lock) SetProperty(_spineObject.DebugClippings, value, _spineObject, (m, v) => m.DebugClippings = v); }
set { lock (_lock) SetProperty(_spineObject.DebugClippings, value, v => _spineObject.DebugClippings = v); }
}
public void Update(float delta)
@@ -326,95 +357,107 @@ namespace SpineViewer.Models
lock (_lock) return _spineObject.GetCurrentBounds();
}
/// <summary>
/// 导出参数对象
/// </summary>
public SpineObjectConfigModel Dump()
public SpineObjectConfigModel ObjectConfig
{
lock (_lock)
get
{
SpineObjectConfigModel config = new()
lock (_lock)
{
Scale = Math.Abs(_spineObject.Skeleton.ScaleX),
FlipX = _spineObject.Skeleton.ScaleX < 0,
FlipY = _spineObject.Skeleton.ScaleY < 0,
X = _spineObject.Skeleton.X,
Y = _spineObject.Skeleton.Y,
UsePma = _spineObject.UsePma,
Physics = _spineObject.Physics.ToString(),
SpineObjectConfigModel config = new()
{
Scale = Math.Abs(_spineObject.Skeleton.ScaleX),
FlipX = _spineObject.Skeleton.ScaleX < 0,
FlipY = _spineObject.Skeleton.ScaleY < 0,
X = _spineObject.Skeleton.X,
Y = _spineObject.Skeleton.Y,
DebugTexture = _spineObject.DebugTexture,
DebugBounds = _spineObject.DebugBounds,
DebugBones = _spineObject.DebugBones,
DebugRegions = _spineObject.DebugRegions,
DebugMeshHulls = _spineObject.DebugMeshHulls,
DebugMeshes = _spineObject.DebugMeshes,
DebugBoundingBoxes = _spineObject.DebugBoundingBoxes,
DebugPaths = _spineObject.DebugPaths,
DebugPoints = _spineObject.DebugPoints,
DebugClippings = _spineObject.DebugClippings
};
UsePma = _spineObject.UsePma,
Physics = _spineObject.Physics.ToString(),
config.LoadedSkins.AddRange(_spineObject.Data.Skins.Select(it => it.Name).Where(_spineObject.GetSkinStatus));
DebugTexture = _spineObject.DebugTexture,
DebugBounds = _spineObject.DebugBounds,
DebugBones = _spineObject.DebugBones,
DebugRegions = _spineObject.DebugRegions,
DebugMeshHulls = _spineObject.DebugMeshHulls,
DebugMeshes = _spineObject.DebugMeshes,
DebugBoundingBoxes = _spineObject.DebugBoundingBoxes,
DebugPaths = _spineObject.DebugPaths,
DebugPoints = _spineObject.DebugPoints,
DebugClippings = _spineObject.DebugClippings
};
foreach (var slot in _spineObject.Skeleton.Slots) config.SlotAttachment[slot.Name] = slot.Attachment?.Name;
config.LoadedSkins.AddRange(_spineObject.Data.Skins.Select(it => it.Name).Where(_spineObject.GetSkinStatus));
// XXX: 处理空动画
config.Animations.AddRange(_spineObject.AnimationState.IterTracks().Select(tr => tr?.Animation.Name));
foreach (var slot in _spineObject.Skeleton.Slots) config.SlotAttachment[slot.Name] = slot.Attachment?.Name;
return config;
// XXX: 处理空动画
config.Animations.AddRange(_spineObject.AnimationState.IterTracks().Select(tr => tr?.Animation.Name));
return config;
}
}
set
{
lock (_lock)
{
_spineObject.Skeleton.ScaleX = value.Scale;
_spineObject.Skeleton.ScaleY = value.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));
foreach (var name in _spineObject.Data.Skins.Select(v => v.Name).Except(value.LoadedSkins))
if (_spineObject.SetSkinStatus(name, false))
SkinStatusChanged?.Invoke(this, new(name, false));
foreach (var name in value.LoadedSkins)
if (_spineObject.SetSkinStatus(name, true))
SkinStatusChanged?.Invoke(this, new(name, true));
foreach (var (slotName, attachmentName) in value.SlotAttachment)
if (_spineObject.SetAttachment(slotName, attachmentName))
SlotAttachmentChanged?.Invoke(this, new(slotName, attachmentName));
// XXX: 处理空动画
_spineObject.AnimationState.ClearTracks();
int trackIndex = 0;
foreach (var name in value.Animations)
{
if (!string.IsNullOrEmpty(name))
_spineObject.AnimationState.SetAnimation(trackIndex, name, true);
AnimationChanged?.Invoke(this, new(trackIndex, name));
trackIndex++;
}
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));
}
}
}
/// <summary>
/// 从参数对象加载参数值
/// </summary>
public void Load(SpineObjectConfigModel config)
public SpineObjectWorkspaceConfigModel WorkspaceConfig
{
lock (_lock)
get
{
_spineObject.Skeleton.ScaleX = config.Scale;
_spineObject.Skeleton.ScaleY = config.Scale;
OnPropertyChanged(nameof(Scale));
SetProperty(_spineObject.Skeleton.ScaleX < 0, config.FlipX, _spineObject, (m, v) => m.Skeleton.ScaleX *= -1, nameof(FlipX));
SetProperty(_spineObject.Skeleton.ScaleY < 0, config.FlipY, _spineObject, (m, v) => m.Skeleton.ScaleY *= -1, nameof(FlipY));
SetProperty(_spineObject.Skeleton.X, config.X, _spineObject, (m, v) => m.Skeleton.X = v, nameof(X));
SetProperty(_spineObject.Skeleton.Y, config.Y, _spineObject, (m, v) => m.Skeleton.Y = v, nameof(Y));
SetProperty(_spineObject.UsePma, config.UsePma, _spineObject, (m, v) => m.UsePma = v, nameof(UsePma));
SetProperty(_spineObject.Physics, Enum.Parse<ISkeleton.Physics>(config.Physics ?? "Update", true), _spineObject, (m, v) => m.Physics = v, nameof(Physics));
foreach (var name in _spineObject.Data.Skins.Select(v => v.Name).Except(config.LoadedSkins))
if (_spineObject.SetSkinStatus(name, false))
SkinStatusChanged?.Invoke(this, new(name, false));
foreach (var name in config.LoadedSkins)
if (_spineObject.SetSkinStatus(name, true))
SkinStatusChanged?.Invoke(this, new(name, true));
foreach (var (slotName, attachmentName) in config.SlotAttachment)
if (_spineObject.SetAttachment(slotName, attachmentName))
SlotAttachmentChanged?.Invoke(this, new(slotName, attachmentName));
// XXX: 处理空动画
_spineObject.AnimationState.ClearTracks();
int trackIndex = 0;
foreach (var name in config.Animations)
return new()
{
if (!string.IsNullOrEmpty(name))
_spineObject.AnimationState.SetAnimation(trackIndex, name, true);
AnimationChanged?.Invoke(this, new(trackIndex, name));
trackIndex++;
}
SetProperty(_spineObject.DebugTexture, config.DebugTexture, _spineObject, (m, v) => m.DebugTexture = v, nameof(DebugTexture));
SetProperty(_spineObject.DebugBounds, config.DebugBounds, _spineObject, (m, v) => m.DebugBounds = v, nameof(DebugBounds));
SetProperty(_spineObject.DebugBones, config.DebugBones, _spineObject, (m, v) => m.DebugBones = v, nameof(DebugBones));
SetProperty(_spineObject.DebugRegions, config.DebugRegions, _spineObject, (m, v) => m.DebugRegions = v, nameof(DebugRegions));
SetProperty(_spineObject.DebugMeshHulls, config.DebugMeshHulls, _spineObject, (m, v) => m.DebugMeshHulls = v, nameof(DebugMeshHulls));
SetProperty(_spineObject.DebugMeshes, config.DebugMeshes, _spineObject, (m, v) => m.DebugMeshes = v, nameof(DebugMeshes));
SetProperty(_spineObject.DebugBoundingBoxes, config.DebugBoundingBoxes, _spineObject, (m, v) => m.DebugBoundingBoxes = v, nameof(DebugBoundingBoxes));
SetProperty(_spineObject.DebugPaths, config.DebugPaths, _spineObject, (m, v) => m.DebugPaths = v, nameof(DebugPaths));
SetProperty(_spineObject.DebugPoints, config.DebugPoints, _spineObject, (m, v) => m.DebugPoints = v, nameof(DebugPoints));
SetProperty(_spineObject.DebugClippings, config.DebugClippings, _spineObject, (m, v) => m.DebugClippings = v, nameof(DebugClippings));
SkelPath = SkelPath,
AtlasPath = AtlasPath,
IsShown = IsShown,
ObjectConfig = ObjectConfig
};
}
}
@@ -475,4 +518,20 @@ namespace SpineViewer.Models
public int TrackIndex { get; } = trackIndex;
public string? AnimationName { get; } = animationName;
}
public class SpineObjectLoadOptions
{
public bool IsShown { get; set; } = true;
public bool UsePma { get; set; }
public bool DebugTexture { get; set; } = true;
public bool DebugBounds { get; set; }
public bool DebugBones { get; set; }
public bool DebugRegions { get; set; }
public bool DebugMeshHulls { get; set; }
public bool DebugMeshes { get; set; }
public bool DebugBoundingBoxes { get; set; }
public bool DebugPaths { get; set; }
public bool DebugPoints { get; set; }
public bool DebugClippings { get; set; }
}
}

View File

@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading.Tasks;
using System.Windows.Media;
namespace SpineViewer.Models
{
public class WorkspaceModel
{
public string? ExploringDirectory { get; set; }
public RendererWorkspaceConfigModel RendererConfig { get; set; } = new();
public List<SpineObjectWorkspaceConfigModel> LoadedSpineObjects { get; set; } = [];
}
public class RendererWorkspaceConfigModel
{
public uint ResolutionX { get; set; } = 100;
public uint ResolutionY { get; set; } = 100;
public float CenterX { get; set; }
public float CenterY { get; set; }
public float Zoom { get; set; } = 1f;
public float Rotation { get; set; }
public bool FlipX { get; set; }
public bool FlipY { get; set; } = true;
public uint MaxFps { get; set; } = 30;
public float Speed { get; set; } = 1f;
public bool ShowAxis { get; set; } = true;
public Color BackgroundColor { get; set; }
// TODO: 背景图片
//public string? BackgroundImagePath { get; set; }
//public ? BackgroundImageDisplayMode { get; set; }
}
public class SpineObjectWorkspaceConfigModel
{
public string SkelPath { get; set; } = "";
public string AtlasPath { get; set; } = "";
public bool IsShown { get; set; } = true;
public SpineObjectConfigModel ObjectConfig { get; set; } = new();
}
}

View File

@@ -19,6 +19,7 @@ namespace SpineViewer.Resources
public static string Str_GeneratePreviewsTitle => Get<string>("Str_GeneratePreviewsTitle");
public static string Str_DeletePreviewsTitle => Get<string>("Str_DeletePreviewsTitle");
public static string Str_AddSpineObjectsTitle => Get<string>("Str_AddSpineObjectsTitle");
public static string Str_ReloadSpineObjectsTitle => Get<string>("Str_ReloadSpineObjectsTitle");
public static string Str_CustomFFmpegExporterTitle => Get<string>("Str_CustomFFmpegExporterTitle");
public static string Str_InfoPopup => Get<string>("Str_InfoPopup");
@@ -39,7 +40,6 @@ namespace SpineViewer.Resources
public static string Str_OutputDirNotFound => Get<string>("Str_OutputDirNotFound");
public static string Str_OutputDirRequired => Get<string>("Str_OutputDirRequired");
public static string Str_InvalidMaxResolution => Get<string>("Str_InvalidMaxResolution");
public static string Str_InvalidDuration => Get<string>("Str_InvalidDuration");
public static string Str_FFmpegFormatRequired => Get<string>("Str_FFmpegFormatRequired");
public static string Str_Copied => Get<string>("Str_Copied");

View File

@@ -11,7 +11,10 @@
<s:String x:Key="Str_Experiment">Experimental Features</s:String>
<s:String x:Key="Str_Open">Open...</s:String>
<s:String x:Key="Str_Preference">Preferences...</s:String>
<s:String x:Key="Str_OpenWorkspace">Open Workspace...</s:String>
<s:String x:Key="Str_SaveWorkspace">Save Workspace...</s:String>
<s:String x:Key="Str_Preference">Preferences</s:String>
<s:String x:Key="Str_PreferenceWithDots">Preferences...</s:String>
<s:String x:Key="Str_Exit">Exit</s:String>
<!-- 标签页 -->
@@ -35,9 +38,10 @@
<s:String x:Key="Str_ListViewStatusBar">{0} items, {1} selected</s:String>
<s:String x:Key="Str_AddSpineObject">Add...</s:String>
<s:String x:Key="Str_RemoveSpineObject">Remove</s:String>
<s:String x:Key="Str_AddSpineObjectFromClipboard">Add from Clipboard</s:String>
<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_AddSpineObjectFromClipboard">Add from Clipboard</s:String>
<s:String x:Key="Str_CopySpineObjectConfig">Copy Config</s:String>
<s:String x:Key="Str_ApplySpineObjectConfig">Apply Config</s:String>
<s:String x:Key="Str_SaveSpineObjectConfigToFile">Save Config to File...</s:String>
@@ -100,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>
@@ -119,6 +124,7 @@
<s:String x:Key="Str_GeneratePreviewsTitle">Generate Previews</s:String>
<s:String x:Key="Str_DeletePreviewsTitle">Delete Previews</s:String>
<s:String x:Key="Str_AddSpineObjectsTitle">Batch Add Spine Files</s:String>
<s:String x:Key="Str_ReloadSpineObjectsTitle">Reload Spine Files</s:String>
<s:String x:Key="Str_InfoPopup">Information</s:String>
<s:String x:Key="Str_WarnPopup">Warning</s:String>
@@ -139,7 +145,6 @@
<s:String x:Key="Str_OutputDirNotFound">Output Directory Not Found</s:String>
<s:String x:Key="Str_OutputDirRequired">Output folder required for single export</s:String>
<s:String x:Key="Str_InvalidMaxResolution">Valid max resolution required when using auto resolution</s:String>
<s:String x:Key="Str_InvalidDuration">Export duration cannot be negative for single export</s:String>
<s:String x:Key="Str_FFmpegFormatRequired">FFmpeg export format is required</s:String>
<s:String x:Key="Str_ResolutionTooltip">Screen resolution; adjust related parameters in the render settings panel</s:String>
@@ -159,9 +164,11 @@
<s:String x:Key="Str_ImageQualityTooltip">Range 0100; only effective for certain formats</s:String>
<s:String x:Key="Str_Duration">Duration</s:String>
<s:String x:Key="Str_ExportDurationTooltip">Export duration; if smaller than 0, each model uses its maximum animation length when exporting individually</s:String>
<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>
@@ -170,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>
@@ -178,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>
@@ -194,4 +203,17 @@
<s:String x:Key="Str_ProgremVersion">Program Version</s:String>
<s:String x:Key="Str_ProjectUrl">Project URL</s:String>
<!-- 首选项对话框 -->
<s:String x:Key="Str_TextureLoadPreference">Texture Loading Options</s:String>
<s:String x:Key="Str_ForcePremul">Force Premultiplied Channels</s:String>
<s:String x:Key="Str_ForcePremulTooltip">When enabled, this applies premultiplied operations to pixels during texture loading, helping to resolve black edge issues at some connections.</s:String>
<s:String x:Key="Str_ForceNearest">Force Nearest Interpolation</s:String>
<s:String x:Key="Str_ForceMipmap">Force Mipmap</s:String>
<s:String x:Key="Str_ForceMipmapTooltip">When enabled, this helps reduce aliasing when textures are scaled down, at the cost of slightly higher video memory usage.</s:String>
<s:String x:Key="Str_SpineLoadPreference">Model Loading Options</s:String>
<s:String x:Key="Str_AppPreference">Application Options</s:String>
<s:String x:Key="Str_Language">Language</s:String>
</ResourceDictionary>

View File

@@ -11,7 +11,10 @@
<s:String x:Key="Str_Experiment">実験機能</s:String>
<s:String x:Key="Str_Open">開く...</s:String>
<s:String x:Key="Str_Preference">設定...</s:String>
<s:String x:Key="Str_OpenWorkspace">ワークスペースを開く...</s:String>
<s:String x:Key="Str_SaveWorkspace">ワークスペースを保存...</s:String>
<s:String x:Key="Str_Preference">設定</s:String>
<s:String x:Key="Str_PreferenceWithDots">設定...</s:String>
<s:String x:Key="Str_Exit">終了</s:String>
<!-- 标签页 -->
@@ -35,9 +38,10 @@
<s:String x:Key="Str_ListViewStatusBar">全{0}件、選択中{1}件</s:String>
<s:String x:Key="Str_AddSpineObject">追加...</s:String>
<s:String x:Key="Str_RemoveSpineObject">削除</s:String>
<s:String x:Key="Str_AddSpineObjectFromClipboard">クリップボードから追加</s:String>
<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_AddSpineObjectFromClipboard">クリップボードから追加</s:String>
<s:String x:Key="Str_CopySpineObjectConfig">パラメーターをコピー</s:String>
<s:String x:Key="Str_ApplySpineObjectConfig">パラメーターを適用</s:String>
<s:String x:Key="Str_SaveSpineObjectConfigToFile">パラメータファイルを保存...</s:String>
@@ -100,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>
@@ -119,6 +124,7 @@
<s:String x:Key="Str_GeneratePreviewsTitle">プレビューを生成</s:String>
<s:String x:Key="Str_DeletePreviewsTitle">プレビューを削除</s:String>
<s:String x:Key="Str_AddSpineObjectsTitle">一括でスケルトンファイルを追加</s:String>
<s:String x:Key="Str_ReloadSpineObjectsTitle">スケルトンファイルの再読み込み</s:String>
<s:String x:Key="Str_InfoPopup">情報</s:String>
<s:String x:Key="Str_WarnPopup">警告</s:String>
@@ -139,7 +145,6 @@
<s:String x:Key="Str_OutputDirNotFound">出力フォルダーが存在しません</s:String>
<s:String x:Key="Str_OutputDirRequired">単一エクスポート時は出力フォルダーを指定する必要があります</s:String>
<s:String x:Key="Str_InvalidMaxResolution">自動解像度使用時は有効な最大解像度を指定する必要があります</s:String>
<s:String x:Key="Str_InvalidDuration">単一エクスポート時、持続時間は0以上である必要があります</s:String>
<s:String x:Key="Str_FFmpegFormatRequired">FFmpegエクスポートフォーマットを指定する必要があります</s:String>
<s:String x:Key="Str_ResolutionTooltip">画面解像度。関連パラメーターは画面パネルで調整してください</s:String>
@@ -159,9 +164,11 @@
<s:String x:Key="Str_ImageQualityTooltip">値の範囲は0-100。一部の画像フォーマットでのみ有効です</s:String>
<s:String x:Key="Str_Duration">時間</s:String>
<s:String x:Key="Str_ExportDurationTooltip">エクスポート時間。0未満の場合、個別エクスポート時には各モデルのすべてのトラックアニメーションの最大時間が使用されます</s:String>
<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>
@@ -170,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>
@@ -178,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>
@@ -194,5 +203,18 @@
<s:String x:Key="Str_ProgremVersion">プログラムバージョン</s:String>
<s:String x:Key="Str_ProjectUrl">プロジェクトURL</s:String>
<!-- 首选项对话框 -->
<s:String x:Key="Str_TextureLoadPreference">テクスチャ読み込みオプション</s:String>
<s:String x:Key="Str_ForcePremul">強制プリマルチチャンネル</s:String>
<s:String x:Key="Str_ForcePremulTooltip">有効にすると、テクスチャ読み込み時にピクセルにプリマルチ処理を適用し、一部の接続部分で発生する黒い縁の問題を解決します。</s:String>
<s:String x:Key="Str_ForceNearest">Nearest補間を強制使用</s:String>
<s:String x:Key="Str_ForceMipmap">Mipmapを強制使用</s:String>
<s:String x:Key="Str_ForceMipmapTooltip">有効にすると、テクスチャ縮小時のジャギーを軽減しますが、ビデオメモリの使用量が若干増加します。</s:String>
<s:String x:Key="Str_SpineLoadPreference">モデル読み込みオプション</s:String>
<s:String x:Key="Str_AppPreference">アプリケーションプション</s:String>
<s:String x:Key="Str_Language">言語</s:String>
</ResourceDictionary>

View File

@@ -11,7 +11,10 @@
<s:String x:Key="Str_Experiment">实验性功能</s:String>
<s:String x:Key="Str_Open">打开...</s:String>
<s:String x:Key="Str_Preference">首选项...</s:String>
<s:String x:Key="Str_OpenWorkspace">打开工作区...</s:String>
<s:String x:Key="Str_SaveWorkspace">保存工作区...</s:String>
<s:String x:Key="Str_Preference">首选项</s:String>
<s:String x:Key="Str_PreferenceWithDots">首选项...</s:String>
<s:String x:Key="Str_Exit">退出</s:String>
<!-- 标签页 -->
@@ -35,9 +38,10 @@
<s:String x:Key="Str_ListViewStatusBar">共 {0} 项,已选择 {1} 项</s:String>
<s:String x:Key="Str_AddSpineObject">添加...</s:String>
<s:String x:Key="Str_RemoveSpineObject">移除</s:String>
<s:String x:Key="Str_AddSpineObjectFromClipboard">从剪贴板添加</s:String>
<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_AddSpineObjectFromClipboard">从剪贴板添加</s:String>
<s:String x:Key="Str_CopySpineObjectConfig">复制参数</s:String>
<s:String x:Key="Str_ApplySpineObjectConfig">应用参数</s:String>
<s:String x:Key="Str_SaveSpineObjectConfigToFile">保存参数文件...</s:String>
@@ -74,7 +78,7 @@
<s:String x:Key="Str_Slot">插槽</s:String>
<s:String x:Key="Str_ClearSlotsAttachment">清除附件</s:String>
<s:String x:Key="Str_Animation">动画</s:String>
<s:String x:Key="Str_AppendTrack">添加</s:String>
<s:String x:Key="Str_InsertTrack">插入</s:String>
@@ -100,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>
@@ -119,6 +124,7 @@
<s:String x:Key="Str_GeneratePreviewsTitle">生成预览图</s:String>
<s:String x:Key="Str_DeletePreviewsTitle">删除预览图</s:String>
<s:String x:Key="Str_AddSpineObjectsTitle">批量添加骨骼文件</s:String>
<s:String x:Key="Str_ReloadSpineObjectsTitle">重新加载骨骼文件</s:String>
<s:String x:Key="Str_InfoPopup">提示信息</s:String>
<s:String x:Key="Str_WarnPopup">警告信息</s:String>
@@ -139,9 +145,8 @@
<s:String x:Key="Str_OutputDirNotFound">输出文件夹不存在</s:String>
<s:String x:Key="Str_OutputDirRequired">导出单个时必须提供输出文件夹</s:String>
<s:String x:Key="Str_InvalidMaxResolution">使用自动分辨率时需要提供有效的最大分辨率</s:String>
<s:String x:Key="Str_InvalidDuration">导出单个时导出时长不能为负数</s:String>
<s:String x:Key="Str_FFmpegFormatRequired">必须指定 FFmpeg 导出格式</s:String>
<s:String x:Key="Str_ResolutionTooltip">画面分辨率,相关参数请在画面参数面板进行调整</s:String>
<s:String x:Key="Str_ExportSingle">导出单个</s:String>
<s:String x:Key="Str_ExportSingleTooltip">勾选后将所选模型在同一个画面上进行导出,且必须提供输出文件夹</s:String>
@@ -159,9 +164,11 @@
<s:String x:Key="Str_ImageQualityTooltip">取值范围 0-100仅对部分图像格式生效</s:String>
<s:String x:Key="Str_Duration">时长</s:String>
<s:String x:Key="Str_ExportDurationTooltip">导出时长,如果小于 0则在逐个导出时每个模型使用各自的所有轨道动画时长最大值</s:String>
<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>
@@ -169,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>
@@ -178,14 +187,14 @@
<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>
<s:String x:Key="Str_FFmpegFilterTooltip">FFmpeg 滤镜,等价于参数 “-vf”</s:String>
<s:String x:Key="Str_FFmpegCustomArgs">自定义参数</s:String>
<s:String x:Key="Str_FFmpegCustomArgsTooltip">FFmpeg 自定义参数,与命令行提供方式相同,例如 “-crf 23”</s:String>
<!-- 诊断信息对话框 -->
<s:String x:Key="Str_CopyDiagnosticsInfo">复制到剪贴板</s:String>
<s:String x:Key="Str_Copied">已复制</s:String>
@@ -193,5 +202,18 @@
<!-- 关于对话框 -->
<s:String x:Key="Str_ProgremVersion">程序版本</s:String>
<s:String x:Key="Str_ProjectUrl">项目地址</s:String>
<!-- 首选项对话框 -->
<s:String x:Key="Str_TextureLoadPreference">纹理加载选项</s:String>
<s:String x:Key="Str_ForcePremul">强制预乘通道</s:String>
<s:String x:Key="Str_ForcePremulTooltip">开启后,会在加载纹理时对像素进行预乘操作,有助于解决某些情况下的连接处黑边问题</s:String>
<s:String x:Key="Str_ForceNearest">强制使用 Nearest 插值</s:String>
<s:String x:Key="Str_ForceMipmap">强制使用 Mipmap</s:String>
<s:String x:Key="Str_ForceMipmapTooltip">开启后有助于改善纹理缩小时的锯齿现象,但是会略微增加显存占用</s:String>
<s:String x:Key="Str_SpineLoadPreference">模型加载选项</s:String>
<s:String x:Key="Str_AppPreference">应用程序选项</s:String>
<s:String x:Key="Str_Language">语言</s:String>
</ResourceDictionary>

View File

@@ -1,18 +0,0 @@
using SpineViewer.Views;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewer.Services
{
public static class AboutDialogService
{
public static bool ShowAboutDialog()
{
var dialog = new AboutDialog() { Owner = App.Current.MainWindow };
return dialog.ShowDialog() ?? false;
}
}
}

View File

@@ -1,18 +0,0 @@
using SpineViewer.Views;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewer.Services
{
public static class DiagnosticsDialogService
{
public static bool ShowDiagnosticsDialog()
{
var dialog = new DiagnosticsDialog() { Owner = App.Current.MainWindow };
return dialog.ShowDialog() ?? false;
}
}
}

View File

@@ -0,0 +1,115 @@
using Microsoft.Win32;
using SpineViewer.Models;
using SpineViewer.ViewModels.Exporters;
using SpineViewer.Views;
using SpineViewer.Views.ExporterDialogs;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewer.Services
{
/// <summary>
/// 用于弹出各种对话框的服务
/// </summary>
public static class DialogService
{
public static bool ShowDiagnosticsDialog()
{
var dialog = new DiagnosticsDialog() { Owner = App.Current.MainWindow };
return dialog.ShowDialog() ?? false;
}
public static bool ShowAboutDialog()
{
var dialog = new AboutDialog() { Owner = App.Current.MainWindow };
return dialog.ShowDialog() ?? false;
}
public static bool ShowFrameExporterDialog(FrameExporterViewModel vm)
{
var dialog = new FrameExporterDialog() { DataContext = vm, Owner = App.Current.MainWindow };
return dialog.ShowDialog() ?? false;
}
public static bool ShowFrameSequenceExporterDialog(FrameSequenceExporterViewModel vm)
{
var dialog = new FrameSequenceExporterDialog() { DataContext = vm, Owner = App.Current.MainWindow };
return dialog.ShowDialog() ?? false;
}
public static bool ShowFFmpegVideoExporterDialog(FFmpegVideoExporterViewModel vm)
{
var dialog = new FFmpegVideoExporterDialog() { DataContext = vm, Owner = App.Current.MainWindow };
return dialog.ShowDialog() ?? false;
}
public static bool ShowCustomFFmpegExporterDialog(CustomFFmpegExporterViewModel vm)
{
var dialog = new CustomFFmpegExporterDialog() { DataContext = vm, Owner = App.Current.MainWindow };
return dialog.ShowDialog() ?? false;
}
/// <summary>
/// 将给定的首选项参数在对话框上进行显示, 返回值表示是否确认修改
/// </summary>
public static bool ShowPreferenceDialog(PreferenceModel m)
{
var dialog = new PreferenceDialog() { DataContext = m, Owner = App.Current.MainWindow };
return dialog.ShowDialog() ?? false;
}
/// <summary>
/// 获取用户选择的文件夹
/// </summary>
/// <param name="folderName"></param>
/// <returns>是否确认了选择</returns>
public static bool ShowOpenFolderDialog(out string? folderName)
{
var dialog = new OpenFolderDialog() { Multiselect = false };
if (dialog.ShowDialog() is true)
{
folderName = dialog.FolderName;
return true;
}
folderName = null;
return false;
}
public static bool ShowOpenJsonDialog(out string? fileName, string initialDirectory = "")
{
var dialog = new OpenFileDialog()
{
InitialDirectory = initialDirectory,
Filter = "Json|*.jcfg;*.json|All|*.*"
};
if (dialog.ShowDialog() is true)
{
fileName = dialog.FileName;
return true;
}
fileName = null;
return false;
}
public static bool ShowSaveJsonDialog(ref string? fileName, string initialDirectory = "")
{
var dialog = new SaveFileDialog()
{
FileName = fileName,
InitialDirectory = initialDirectory,
DefaultExt = ".jcfg",
Filter = "Json|*.jcfg;*.json|All|*.*",
};
if (dialog.ShowDialog() is true)
{
fileName = dialog.FileName;
return true;
}
fileName = null;
return false;
}
}
}

View File

@@ -1,38 +0,0 @@
using SpineViewer.ViewModels.Exporters;
using SpineViewer.Views;
using SpineViewer.Views.ExporterDialogs;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewer.Services
{
public static class ExporterDialogService
{
public static bool ShowFrameExporterDialog(FrameExporterViewModel vm)
{
var dialog = new FrameExporterDialog() { DataContext = vm, Owner = App.Current.MainWindow };
return dialog.ShowDialog() ?? false;
}
public static bool ShowFrameSequenceExporterDialog(FrameSequenceExporterViewModel vm)
{
var dialog = new FrameSequenceExporterDialog() { DataContext = vm, Owner = App.Current.MainWindow };
return dialog.ShowDialog() ?? false;
}
public static bool ShowFFmpegVideoExporterDialog(FFmpegVideoExporterViewModel vm)
{
var dialog = new FFmpegVideoExporterDialog() { DataContext = vm, Owner = App.Current.MainWindow };
return dialog.ShowDialog() ?? false;
}
public static bool ShowCustomFFmpegExporterDialog(CustomFFmpegExporterViewModel vm)
{
var dialog = new CustomFFmpegExporterDialog() { DataContext = vm, Owner = App.Current.MainWindow };
return dialog.ShowDialog() ?? false;
}
}
}

View File

@@ -1,29 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewer.Services
{
public static class OpenFolderService
{
/// <summary>
/// 获取用户选择的文件夹
/// </summary>
/// <param name="selectedPath"></param>
/// <returns>是否确认了选择</returns>
public static bool OpenFolder(out string? selectedPath)
{
// XXX: 此处使用了 System.Windows.Forms 的文件夹浏览对话框
using var folderDialog = new System.Windows.Forms.FolderBrowserDialog();
if (folderDialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
selectedPath = folderDialog.SelectedPath;
return true;
}
selectedPath = null;
return false;
}
}
}

View File

@@ -1,25 +0,0 @@
using Microsoft.Win32;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SpineViewer.Services
{
public static class SaveService
{
/// <summary>
/// 获取用户选择的文件夹
/// </summary>
/// <param name="selectedPath"></param>
/// <returns>是否确认了选择</returns>
public static bool SaveFile(out string? selectedPath)
{
var dialog = new SaveFileDialog() { };
selectedPath = null;
// TODO
return false;
}
}
}

View File

@@ -7,21 +7,16 @@
<TargetFramework>net8.0-windows</TargetFramework>
<BaseOutputPath>$(SolutionDir)out</BaseOutputPath>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Version>0.15.0</Version>
<Version>0.15.8</Version>
<OutputType>WinExe</OutputType>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);NETSDK1206</NoWarn>
<ApplicationIcon>appicon.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<Using Remove="System.Windows.Forms" />
<Using Remove="System.Drawing" />
</ItemGroup>
<ItemGroup>
<Content Include="appicon.ico" />

View File

@@ -0,0 +1,123 @@
using FFMpegCore;
using Microsoft.Win32;
using NLog;
using SpineViewer.Models;
using SpineViewer.Services;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using System.Windows.Media;
namespace SpineViewer.Utils
{
public static class JsonHelper
{
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
static JsonHelper()
{
_jsonOptions.Converters.Add(new ColorJsonConverter());
}
/// <summary>
/// 保存 Json 文件的格式参数
/// </summary>
public static JsonSerializerOptions JsonOptions => _jsonOptions;
private static readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip
};
/// <summary>
/// 从文件反序列对象, 不会抛出异常
/// </summary>
public static bool Deserialize<T>(string path, out T obj, bool quietForNotExist = false)
{
if (!File.Exists(path))
{
if (!quietForNotExist)
{
_logger.Error("Json file {0} not found", path);
MessagePopupService.Error($"Json file {path} not found");
}
}
else
{
try
{
var json = File.ReadAllText(path, Encoding.UTF8);
var model = JsonSerializer.Deserialize<T>(json, _jsonOptions);
if (model is T m)
{
obj = m;
return true;
}
_logger.Error("Null data in file {0}", path);
MessagePopupService.Error($"Null data in file {path}");
}
catch (Exception ex)
{
_logger.Error("Failed to read json file {0}, {1}", path, ex.Message);
_logger.Trace(ex.ToString());
MessagePopupService.Error($"Failed to read json file {path}, {ex.ToString()}");
}
}
obj = default;
return false;
}
/// <summary>
/// 保存至文件, 不会抛出异常
/// </summary>
public static bool Serialize<T>(T obj, string path)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path));
var json = JsonSerializer.Serialize(obj, _jsonOptions);
File.WriteAllText(path, json, Encoding.UTF8);
}
catch (Exception ex)
{
_logger.Error("Failed to save json file {0}, {1}", path, ex.Message);
_logger.Trace(ex.ToString());
MessagePopupService.Error($"Failed to save json file {path}, {ex.ToString()}");
return false;
}
return true;
}
}
public class ColorJsonConverter : JsonConverter<Color>
{
public override Color Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// 解析 JSON 对象
var jsonObject = JsonDocument.ParseValue(ref reader).RootElement;
var r = jsonObject.GetProperty("R").GetByte();
var g = jsonObject.GetProperty("G").GetByte();
var b = jsonObject.GetProperty("B").GetByte();
var a = jsonObject.GetProperty("A").GetByte();
return Color.FromArgb(a, r, g, b);
}
public override void Write(Utf8JsonWriter writer, Color value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteNumber("R", value.R);
writer.WriteNumber("G", value.G);
writer.WriteNumber("B", value.B);
writer.WriteNumber("A", value.A);
writer.WriteEndObject();
}
}
}

View File

@@ -6,7 +6,7 @@ using System.Text;
using System.Threading.Tasks;
using System.Windows.Data;
namespace SpineViewer.Extensions
namespace SpineViewer.Utils
{
public class ObservableCollectionWithLock<T> : ObservableCollection<T>
{

View File

@@ -7,7 +7,7 @@ using System.Threading.Tasks;
using System.Windows;
using System.Windows.Data;
namespace SpineViewer.Extensions
namespace SpineViewer.Utils
{
public class StringFormatMultiValueConverter : IMultiValueConverter
{
@@ -16,7 +16,7 @@ namespace SpineViewer.Extensions
if (values == null || values.Length <= 0)
return DependencyProperty.UnsetValue;
if (App.Current.TryFindResource(parameter) is string format)
if (Application.Current.TryFindResource(parameter) is string format)
{
return string.Format(culture, format, values);
}

View File

@@ -12,6 +12,7 @@ using System.Text;
using System.Threading.Tasks;
using System.Windows;
namespace SpineViewer.ViewModels
{
public class DiagnosticsDialogViewModel : ObservableObject
@@ -31,7 +32,20 @@ namespace SpineViewer.ViewModels
}
}
public string Memory => $"{new Microsoft.VisualBasic.Devices.ComputerInfo().TotalPhysicalMemory / 1024f / 1024f / 1024f:F1} GB";
public string Memory
{
get
{
var searcher = new ManagementObjectSearcher("SELECT TotalPhysicalMemory FROM Win32_ComputerSystem");
foreach (ManagementObject obj in searcher.Get())
{
ulong bytes = (ulong)obj["TotalPhysicalMemory"];
float gb = bytes / 1024f / 1024f / 1024f;
return $"{gb:F1} GB";
}
return "Unknown";
}
}
public string WindowsVersion
{

View File

@@ -5,7 +5,9 @@ using SFMLRenderer;
using Spine;
using Spine.Exporters;
using SpineViewer.Extensions;
using SpineViewer.Models;
using SpineViewer.Resources;
using SpineViewer.ViewModels.MainWindow;
using System;
using System.Collections;
using System.Collections.Generic;
@@ -52,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>
/// 四周边缘距离
@@ -100,18 +102,19 @@ namespace SpineViewer.ViewModels.Exporters
/// </summary>
protected void SetAutoResolutionStatic(BaseExporter exporter, params SpineObject[] spines)
{
var bounds = spines[0].GetAnimationBounds();
foreach (var sp in spines.Skip(1)) bounds.Union(sp.GetAnimationBounds());
var bounds = spines[0].GetCurrentBounds();
foreach (var sp in spines.Skip(1)) bounds.Union(sp.GetCurrentBounds());
SetAutoResolution(exporter, bounds);
}
/// <summary>
/// 使用提供的模型设置导出器的自动分辨率和视区参数, 动画画面
/// </summary>
protected void SetAutoResolutionAnimated(BaseExporter exporter, params SpineObject[] spines)
protected void SetAutoResolutionAnimated(VideoExporter exporter, params SpineObject[] spines)
{
var bounds = spines[0].GetAnimationBounds();
foreach (var sp in spines.Skip(1)) bounds.Union(sp.GetAnimationBounds());
var fps = exporter.Fps;
var bounds = spines[0].GetAnimationBounds(fps);
foreach (var sp in spines.Skip(1)) bounds.Union(sp.GetAnimationBounds(fps));
SetAutoResolution(exporter, bounds);
}
@@ -132,9 +135,20 @@ namespace SpineViewer.ViewModels.Exporters
return null;
}
public RelayCommand<IList?> Cmd_Export => _cmd_Export ??= new(Export_Execute, args => args is not null && args.Count > 0);
public RelayCommand<IList?> Cmd_Export => _cmd_Export ??= new(Export_Execute, Export_CanExecute);
private RelayCommand<IList?>? _cmd_Export;
protected abstract void Export_Execute(IList? args);
private void Export_Execute(IList? args)
{
if (!Export_CanExecute(args)) return;
Export(args.Cast<SpineObjectModel>().ToArray());
}
private bool Export_CanExecute(IList? args)
{
return args is not null && args.Count > 0;
}
protected abstract void Export(SpineObjectModel[] models);
}
}

View File

@@ -12,6 +12,7 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.IO;
using SpineViewer.ViewModels.MainWindow;
namespace SpineViewer.ViewModels.Exporters
{
@@ -46,11 +47,10 @@ namespace SpineViewer.ViewModels.Exporters
return null;
}
protected override void Export_Execute(IList? args)
protected override void Export(SpineObjectModel[] models)
{
if (args is null || args.Count <= 0) return;
if (!ExporterDialogService.ShowCustomFFmpegExporterDialog(this)) return;
SpineObject[] spines = args.Cast<SpineObjectModel>().Select(m => m.GetSpineObject()).ToArray();
if (!DialogService.ShowCustomFFmpegExporterDialog(this)) return;
SpineObject[] spines = models.Select(m => m.GetSpineObject()).ToArray();
ProgressService.RunAsync((pr, ct) => ExportTask(spines, pr, ct), AppResource.Str_CustomFFmpegExporterTitle);
foreach (var sp in spines) sp.Dispose();
}
@@ -63,8 +63,8 @@ namespace SpineViewer.ViewModels.Exporters
using var exporter = new CustomFFmpegExporter(_renderer.Resolution.X + _margin * 2, _renderer.Resolution.Y + _margin * 2)
{
BackgroundColor = new(_backgroundColor.R, _backgroundColor.G, _backgroundColor.B, _backgroundColor.A),
Duration = _duration,
Fps = _fps,
Speed = _speed,
KeepLast = _keepLast,
Format = _format,
Codec = _codec,
@@ -84,6 +84,8 @@ namespace SpineViewer.ViewModels.Exporters
exporter.Rotation = view.Rotation;
}
_vmMain.SFMLRendererViewModel.StopRender();
if (_exportSingle)
{
var filename = $"ffmpeg_{timestamp}_{Guid.NewGuid().ToString()[..6]}_{_fps}{FormatSuffix}";
@@ -91,6 +93,9 @@ namespace SpineViewer.ViewModels.Exporters
if (_autoResolution) SetAutoResolutionAnimated(exporter, spines);
// 如果时长是一个负数值则使用所有动画时长的最大值
exporter.Duration = _duration < 0 ? spines.Select(sp => sp.GetAnimationMaxDuration()).DefaultIfEmpty(0).Max() : _duration;
exporter.ProgressReporter = (total, done, text) =>
{
pr.Total = total;
@@ -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

@@ -12,12 +12,13 @@ using System.Text;
using System.Threading.Tasks;
using System.IO;
using System.Collections.Immutable;
using SpineViewer.ViewModels.MainWindow;
namespace SpineViewer.ViewModels.Exporters
{
public class FFmpegVideoExporterViewModel(MainWindowViewModel vmMain) : VideoExporterViewModel(vmMain)
{
public ImmutableArray<FFmpegVideoExporter.VideoFormat> VideoFormats { get; } = Enum.GetValues<FFmpegVideoExporter.VideoFormat>().ToImmutableArray();
public ImmutableArray<FFmpegVideoExporter.VideoFormat> VideoFormatOptions { get; } = Enum.GetValues<FFmpegVideoExporter.VideoFormat>().ToImmutableArray();
public FFmpegVideoExporter.VideoFormat Format { get => _format; set => SetProperty(ref _format, value); }
protected FFmpegVideoExporter.VideoFormat _format = FFmpegVideoExporter.VideoFormat.Mp4;
@@ -28,16 +29,18 @@ 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;
private string FormatSuffix => $".{_format.ToString().ToLower()}";
protected override void Export_Execute(IList? args)
protected override void Export(SpineObjectModel[] models)
{
if (args is null || args.Count <= 0) return;
if (!ExporterDialogService.ShowFFmpegVideoExporterDialog(this)) return;
SpineObject[] spines = args.Cast<SpineObjectModel>().Select(m => m.GetSpineObject()).ToArray();
if (!DialogService.ShowFFmpegVideoExporterDialog(this)) return;
SpineObject[] spines = models.Select(m => m.GetSpineObject()).ToArray();
ProgressService.RunAsync((pr, ct) => ExportTask(spines, pr, ct), AppResource.Str_FFmpegVideoExporterTitle);
foreach (var sp in spines) sp.Dispose();
}
@@ -50,12 +53,13 @@ namespace SpineViewer.ViewModels.Exporters
using var exporter = new FFmpegVideoExporter(_renderer.Resolution.X + _margin * 2, _renderer.Resolution.Y + _margin * 2)
{
BackgroundColor = new(_backgroundColor.R, _backgroundColor.G, _backgroundColor.B, _backgroundColor.A),
Duration = _duration,
Fps = _fps,
Speed = _speed,
KeepLast = _keepLast,
Format = _format,
Loop = _loop,
Quality = _quality,
Lossless = _lossless,
Crf = _crf
};
@@ -69,6 +73,10 @@ 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}";
@@ -76,6 +84,9 @@ namespace SpineViewer.ViewModels.Exporters
if (_autoResolution) SetAutoResolutionAnimated(exporter, spines);
// 如果时长是一个负数值则使用所有动画时长的最大值
exporter.Duration = _duration < 0 ? spines.Select(sp => sp.GetAnimationMaxDuration()).DefaultIfEmpty(0).Max() : _duration;
exporter.ProgressReporter = (total, done, text) =>
{
pr.Total = total;
@@ -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

@@ -6,6 +6,7 @@ using SpineViewer.Extensions;
using SpineViewer.Models;
using SpineViewer.Resources;
using SpineViewer.Services;
using SpineViewer.ViewModels.MainWindow;
using System;
using System.Collections;
using System.Collections.Generic;
@@ -19,7 +20,7 @@ namespace SpineViewer.ViewModels.Exporters
{
public class FrameExporterViewModel(MainWindowViewModel vmMain) : BaseExporterViewModel(vmMain)
{
public ImmutableArray<SKEncodedImageFormat> FrameFormats { get; } = Enum.GetValues<SKEncodedImageFormat>().ToImmutableArray();
public ImmutableArray<SKEncodedImageFormat> FrameFormatOptions { get; } = Enum.GetValues<SKEncodedImageFormat>().ToImmutableArray();
public SKEncodedImageFormat Format { get => _format; set => SetProperty(ref _format, value); }
protected SKEncodedImageFormat _format = SKEncodedImageFormat.Png;
@@ -37,11 +38,10 @@ namespace SpineViewer.ViewModels.Exporters
}
}
protected override void Export_Execute(IList? args)
protected override void Export(SpineObjectModel[] models)
{
if (args is null || args.Count <= 0) return;
if (!ExporterDialogService.ShowFrameExporterDialog(this)) return;
SpineObject[] spines = args.Cast<SpineObjectModel>().Select(m => m.GetSpineObject(true)).ToArray();
if (!DialogService.ShowFrameExporterDialog(this)) return;
SpineObject[] spines = models.Select(m => m.GetSpineObject(true)).ToArray();
ProgressService.RunAsync((pr, ct) => ExportTask(spines, pr, ct), AppResource.Str_FrameExporterTitle);
foreach (var sp in spines) sp.Dispose();
}

View File

@@ -4,6 +4,7 @@ using SpineViewer.Extensions;
using SpineViewer.Models;
using SpineViewer.Resources;
using SpineViewer.Services;
using SpineViewer.ViewModels.MainWindow;
using System;
using System.Collections;
using System.Collections.Generic;
@@ -16,11 +17,10 @@ namespace SpineViewer.ViewModels.Exporters
{
public class FrameSequenceExporterViewModel(MainWindowViewModel vmMain) : VideoExporterViewModel(vmMain)
{
protected override void Export_Execute(IList? args)
protected override void Export(SpineObjectModel[] models)
{
if (args is null || args.Count <= 0) return;
if (!ExporterDialogService.ShowFrameSequenceExporterDialog(this)) return;
SpineObject[] spines = args.Cast<SpineObjectModel>().Select(m => m.GetSpineObject()).ToArray();
if (!DialogService.ShowFrameSequenceExporterDialog(this)) return;
SpineObject[] spines = models.Select(m => m.GetSpineObject()).ToArray();
ProgressService.RunAsync((pr, ct) => ExportTask(spines, pr, ct), AppResource.Str_FrameSequenceExporterTitle);
foreach (var sp in spines) sp.Dispose();
}
@@ -33,8 +33,8 @@ namespace SpineViewer.ViewModels.Exporters
using var exporter = new FrameSequenceExporter(_renderer.Resolution.X + _margin * 2, _renderer.Resolution.Y + _margin * 2)
{
BackgroundColor = new(_backgroundColor.R, _backgroundColor.G, _backgroundColor.B, _backgroundColor.A),
Duration = _duration,
Fps = _fps,
Speed = _speed,
KeepLast = _keepLast
};
@@ -48,6 +48,8 @@ namespace SpineViewer.ViewModels.Exporters
exporter.Rotation = view.Rotation;
}
_vmMain.SFMLRendererViewModel.StopRender();
if (_exportSingle)
{
var folderName = $"frames_{timestamp}_{Guid.NewGuid().ToString()[..6]}_{_fps}";
@@ -55,10 +57,13 @@ namespace SpineViewer.ViewModels.Exporters
if (_autoResolution) SetAutoResolutionAnimated(exporter, spines);
// 如果时长是一个负数值则使用所有动画时长的最大值
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

@@ -1,4 +1,5 @@
using SpineViewer.Resources;
using SpineViewer.ViewModels.MainWindow;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -15,16 +16,10 @@ 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;
public override string? Validate()
{
if (base.Validate() is string err)
return err;
if (_exportSingle && _duration <= 0)
return AppResource.Str_InvalidDuration;
return null;
}
}
}

View File

@@ -8,7 +8,6 @@ using SpineViewer.Extensions;
using SpineViewer.Models;
using SpineViewer.Resources;
using SpineViewer.Services;
using SpineViewer.ViewModels;
using System;
using System.Collections;
using System.Collections.Generic;
@@ -22,7 +21,7 @@ using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shell;
namespace SpineViewer.ViewModels
namespace SpineViewer.ViewModels.MainWindow
{
public class ExplorerListViewModel : ObservableObject
{
@@ -40,11 +39,6 @@ namespace SpineViewer.ViewModels
private readonly MainWindowViewModel _vmMain;
/// <summary>
/// 当前目录路径
/// </summary>
private string? _currentDirectory;
/// <summary>
/// 当前目录下文件项缓存
/// </summary>
@@ -55,12 +49,26 @@ namespace SpineViewer.ViewModels
_vmMain = vmMain;
}
/// <summary>
/// 当前目录路径
/// </summary>
public string? CurrentDirectory
{
get => string.IsNullOrWhiteSpace(_currentDirectory) ? null : _currentDirectory;
set
{
if (!SetProperty(ref _currentDirectory, value)) return;
RefreshItems();
}
}
private string? _currentDirectory;
/// <summary>
/// 筛选字符串
/// </summary>
public string? FilterString
{
get => _filterString;
get => string.IsNullOrWhiteSpace(_filterString) ? null : _filterString;
set
{
if (!SetProperty(ref _filterString, value)) return;
@@ -95,11 +103,8 @@ namespace SpineViewer.ViewModels
/// </summary>
public RelayCommand Cmd_ChangeCurrentDirectory => _cmd_ChangeCurrentDirectory ??= new(() =>
{
if (OpenFolderService.OpenFolder(out var selectedPath))
{
_currentDirectory = selectedPath;
RefreshItems();
}
if (DialogService.ShowOpenFolderDialog(out var selectedPath))
CurrentDirectory = selectedPath;
});
private RelayCommand? _cmd_ChangeCurrentDirectory;

View File

@@ -1,25 +1,13 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HandyControl.Controls;
using NLog;
using SFMLRenderer;
using Spine;
using Spine.Exporters;
using SpineViewer.Extensions;
using SpineViewer.Models;
using SpineViewer.Services;
using SpineViewer.ViewModels.Exporters;
using System;
using System.Collections;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using SpineViewer.Utils;
using System.Windows.Shell;
namespace SpineViewer.ViewModels
namespace SpineViewer.ViewModels.MainWindow
{
/// <summary>
/// MainWindow 上下文对象
@@ -34,9 +22,10 @@ namespace SpineViewer.ViewModels
_explorerListViewModel = new(this);
_spineObjectListViewModel = new(this);
_sfmlRendererViewModel = new(this);
_preferenceViewModel = new(this);
}
public string Title => $"SpineViewer - {App.Version}";
public string Title => $"SpineViewer - v{App.Version}";
/// <summary>
/// SFML 渲染对象
@@ -56,6 +45,9 @@ namespace SpineViewer.ViewModels
public ObservableCollectionWithLock<SpineObjectModel> SpineObjects => _spineObjectModels;
private readonly ObservableCollectionWithLock<SpineObjectModel> _spineObjectModels = [];
public PreferenceViewModel PreferenceViewModel => _preferenceViewModel;
private readonly PreferenceViewModel _preferenceViewModel;
/// <summary>
/// 浏览页列表 ViewModel
/// </summary>
@@ -80,18 +72,65 @@ namespace SpineViewer.ViewModels
public SFMLRendererViewModel SFMLRendererViewModel => _sfmlRendererViewModel;
private readonly SFMLRendererViewModel _sfmlRendererViewModel;
/// <summary>
/// 打开工作区
/// </summary>
public RelayCommand Cmd_OpenWorkspace => _cmd_OpenWorkspace ??= new(OpenWorkspace_Execute);
private RelayCommand? _cmd_OpenWorkspace;
private void OpenWorkspace_Execute()
{
if (!DialogService.ShowOpenJsonDialog(out var fileName)) return;
if (JsonHelper.Deserialize<WorkspaceModel>(fileName, out var obj))
{
Workspace = obj;
}
}
/// <summary>
/// 保存工作区
/// </summary>
public RelayCommand Cmd_SaveWorkspace => _cmd_SaveWorkspace ??= new(SaveWorkspace_Execute);
private RelayCommand? _cmd_SaveWorkspace;
private void SaveWorkspace_Execute()
{
string fileName = "workspace.jcfg";
if (!DialogService.ShowSaveJsonDialog(ref fileName)) return;
JsonHelper.Serialize(Workspace, fileName);
}
/// <summary>
/// 显示诊断信息对话框
/// </summary>
public RelayCommand Cmd_ShowDiagnosticsDialog => _cmd_ShowDiagnosticsDialog ??= new(() => { DiagnosticsDialogService.ShowDiagnosticsDialog(); });
public RelayCommand Cmd_ShowDiagnosticsDialog => _cmd_ShowDiagnosticsDialog ??= new(() => { DialogService.ShowDiagnosticsDialog(); });
private RelayCommand? _cmd_ShowDiagnosticsDialog;
/// <summary>
/// 显示关于对话框
/// </summary>
public RelayCommand Cmd_ShowAboutDialog => _cmd_ShowAboutDialog ??= new(() => { AboutDialogService.ShowAboutDialog(); });
public RelayCommand Cmd_ShowAboutDialog => _cmd_ShowAboutDialog ??= new(() => { DialogService.ShowAboutDialog(); });
private RelayCommand? _cmd_ShowAboutDialog;
public WorkspaceModel Workspace
{
get
{
return new()
{
ExploringDirectory = _explorerListViewModel.CurrentDirectory,
RendererConfig = _sfmlRendererViewModel.WorkspaceConfig,
LoadedSpineObjects = _spineObjectListViewModel.LoadedSpineObjects
};
}
set
{
_explorerListViewModel.CurrentDirectory = value.ExploringDirectory;
_sfmlRendererViewModel.WorkspaceConfig = value.RendererConfig;
_spineObjectListViewModel.LoadedSpineObjects = value.LoadedSpineObjects;
}
}
/// <summary>
/// 调试命令
/// </summary>
@@ -101,31 +140,8 @@ namespace SpineViewer.ViewModels
private void Debug_Execute()
{
#if DEBUG
var path = @"C:\Users\ljh\Desktop\a.mp4";
using var exporter = new FFmpegVideoExporter(_sfmlRenderer.Resolution);
using var view = _sfmlRenderer.GetView();
exporter.Center = view.Center;
exporter.Size = view.Size;
exporter.Rotation = view.Rotation;
exporter.Viewport = view.Viewport;
SpineObject[] spines;
lock (_spineObjectModels.Lock)
{
spines = _spineObjectModels.Select(it => it.GetSpineObject(true)).ToArray();
}
exporter.Fps = 60;
exporter.Format = FFmpegVideoExporter.VideoFormat.Webm;
exporter.Duration = 3;
exporter.BackgroundColor = new(0, 0, 0, 0);
ProgressService.RunAsync((pr, ct) =>
{
exporter.ProgressReporter = (total, done, text) =>
{ pr.Total = total; pr.Done = done; pr.ProgressText = text; };
exporter.Export(path, ct, spines);
}, "测试一下");
MessagePopupService.Quest("测试一下");
#endif
}
}

View File

@@ -0,0 +1,241 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using NLog;
using Spine.SpineWrappers;
using SpineViewer.Models;
using SpineViewer.Services;
using SpineViewer.Utils;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Media;
namespace SpineViewer.ViewModels.MainWindow
{
public class PreferenceViewModel : ObservableObject
{
/// <summary>
/// 文件保存路径
/// </summary>
public static readonly string PreferenceFilePath = Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), "preference.json");
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
private readonly MainWindowViewModel _vmMain;
public PreferenceViewModel(MainWindowViewModel vmMain)
{
_vmMain = vmMain;
}
/// <summary>
/// 显示首选项对话框
/// </summary>
public RelayCommand Cmd_ShowPreferenceDialog => _cmd_ShowPreferenceDialog ??= new(ShowPreferenceDialog_Execute);
private RelayCommand? _cmd_ShowPreferenceDialog;
private void ShowPreferenceDialog_Execute()
{
var m = Preference;
if (!DialogService.ShowPreferenceDialog(m))
return;
Preference = m;
SavePreference(m);
}
private static void SavePreference(PreferenceModel m)
{
JsonHelper.Serialize(m, PreferenceFilePath);
}
/// <summary>
/// 保存首选项, 保存失败会有日志提示
/// </summary>
public void SavePreference() => SavePreference(Preference);
/// <summary>
/// 加载首选项, 加载失败会有日志提示
/// </summary>
public void LoadPreference()
{
if (JsonHelper.Deserialize<PreferenceModel>(PreferenceFilePath, out var obj, true))
Preference = obj;
}
/// <summary>
/// 获取参数副本或者进行设置
/// </summary>
private PreferenceModel Preference
{
get
{
return new()
{
ForcePremul = ForcePremul,
ForceNearest = ForceNearest,
ForceMipmap = ForceMipmap,
IsShown = IsShown,
UsePma = UsePma,
DebugTexture = DebugTexture,
DebugBounds = DebugBounds,
DebugBones = DebugBones,
DebugRegions = DebugRegions,
DebugMeshHulls = DebugMeshHulls,
DebugMeshes = DebugMeshes,
DebugBoundingBoxes = DebugBoundingBoxes,
DebugPaths = DebugPaths,
DebugPoints = DebugPoints,
DebugClippings = DebugClippings,
RenderSelectedOnly = RenderSelectedOnly,
AppLanguage = AppLanguage,
};
}
set
{
ForcePremul = value.ForcePremul;
ForceNearest = value.ForceNearest;
ForceMipmap = value.ForceMipmap;
IsShown = value.IsShown;
UsePma = value.UsePma;
DebugTexture = value.DebugTexture;
DebugBounds = value.DebugBounds;
DebugBones = value.DebugBones;
DebugRegions = value.DebugRegions;
DebugMeshHulls = value.DebugMeshHulls;
DebugMeshes = value.DebugMeshes;
DebugBoundingBoxes = value.DebugBoundingBoxes;
DebugPaths = value.DebugPaths;
DebugPoints = value.DebugPoints;
DebugClippings = value.DebugClippings;
RenderSelectedOnly = value.RenderSelectedOnly;
AppLanguage = value.AppLanguage;
}
}
#region
public bool ForcePremul
{
get => TextureLoader.DefaultLoader.ForcePremul;
set => SetProperty(TextureLoader.DefaultLoader.ForcePremul, value, v => TextureLoader.DefaultLoader.ForcePremul = v);
}
public bool ForceNearest
{
get => TextureLoader.DefaultLoader.ForceNearest;
set => SetProperty(TextureLoader.DefaultLoader.ForceNearest, value, v => TextureLoader.DefaultLoader.ForceNearest = v);
}
public bool ForceMipmap
{
get => TextureLoader.DefaultLoader.ForceMipmap;
set => SetProperty(TextureLoader.DefaultLoader.ForceMipmap, value, v => TextureLoader.DefaultLoader.ForceMipmap = v);
}
#endregion
#region
public bool IsShown
{
get => SpineObjectModel.LoadOptions.IsShown;
set => SetProperty(SpineObjectModel.LoadOptions.IsShown, value, v => SpineObjectModel.LoadOptions.IsShown = v);
}
public bool UsePma
{
get => SpineObjectModel.LoadOptions.UsePma;
set => SetProperty(SpineObjectModel.LoadOptions.UsePma, value, v => SpineObjectModel.LoadOptions.UsePma = v);
}
public bool DebugTexture
{
get => SpineObjectModel.LoadOptions.DebugTexture;
set => SetProperty(SpineObjectModel.LoadOptions.DebugTexture, value, v => SpineObjectModel.LoadOptions.DebugTexture = v);
}
public bool DebugBounds
{
get => SpineObjectModel.LoadOptions.DebugBounds;
set => SetProperty(SpineObjectModel.LoadOptions.DebugBounds, value, v => SpineObjectModel.LoadOptions.DebugBounds = v);
}
public bool DebugBones
{
get => SpineObjectModel.LoadOptions.DebugBones;
set => SetProperty(SpineObjectModel.LoadOptions.DebugBones, value, v => SpineObjectModel.LoadOptions.DebugBones = v);
}
public bool DebugRegions
{
get => SpineObjectModel.LoadOptions.DebugRegions;
set => SetProperty(SpineObjectModel.LoadOptions.DebugRegions, value, v => SpineObjectModel.LoadOptions.DebugRegions = v);
}
public bool DebugMeshHulls
{
get => SpineObjectModel.LoadOptions.DebugMeshHulls;
set => SetProperty(SpineObjectModel.LoadOptions.DebugMeshHulls, value, v => SpineObjectModel.LoadOptions.DebugMeshHulls = v);
}
public bool DebugMeshes
{
get => SpineObjectModel.LoadOptions.DebugMeshes;
set => SetProperty(SpineObjectModel.LoadOptions.DebugMeshes, value, v => SpineObjectModel.LoadOptions.DebugMeshes = v);
}
public bool DebugBoundingBoxes
{
get => SpineObjectModel.LoadOptions.DebugBoundingBoxes;
set => SetProperty(SpineObjectModel.LoadOptions.DebugBoundingBoxes, value, v => SpineObjectModel.LoadOptions.DebugBoundingBoxes = v);
}
public bool DebugPaths
{
get => SpineObjectModel.LoadOptions.DebugPaths;
set => SetProperty(SpineObjectModel.LoadOptions.DebugPaths, value, v => SpineObjectModel.LoadOptions.DebugPaths = v);
}
public bool DebugPoints
{
get => SpineObjectModel.LoadOptions.DebugPoints;
set => SetProperty(SpineObjectModel.LoadOptions.DebugPoints, value, v => SpineObjectModel.LoadOptions.DebugPoints = v);
}
public bool DebugClippings
{
get => SpineObjectModel.LoadOptions.DebugClippings;
set => SetProperty(SpineObjectModel.LoadOptions.DebugClippings, value, v => SpineObjectModel.LoadOptions.DebugClippings = v);
}
#endregion
#region
public static ImmutableArray<AppLanguage> AppLanguageOptions { get; } = Enum.GetValues<AppLanguage>().ToImmutableArray();
public bool RenderSelectedOnly
{
get => _vmMain.SFMLRendererViewModel.RenderSelectedOnly;
set => SetProperty(_vmMain.SFMLRendererViewModel.RenderSelectedOnly, value, v => _vmMain.SFMLRendererViewModel.RenderSelectedOnly = v);
}
public AppLanguage AppLanguage
{
get => ((App)App.Current).Language;
set => SetProperty(((App)App.Current).Language, value, v => ((App)App.Current).Language = v);
}
#endregion
}
}

View File

@@ -7,6 +7,7 @@ using SpineViewer.Extensions;
using SpineViewer.Models;
using SpineViewer.Resources;
using SpineViewer.Services;
using SpineViewer.Utils;
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
@@ -18,7 +19,7 @@ using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
namespace SpineViewer.ViewModels
namespace SpineViewer.ViewModels.MainWindow
{
public class SFMLRendererViewModel : ObservableObject
{
@@ -31,6 +32,16 @@ namespace SpineViewer.ViewModels
private readonly ObservableCollectionWithLock<SpineObjectModel> _models;
private readonly ISFMLRenderer _renderer;
/// <summary>
/// 被选中对象的背景颜色
/// </summary>
private static readonly SFML.Graphics.Color _selectedBackgroundColor = new(255, 255, 255, 50);
/// <summary>
/// 被选中对象背景顶点缓冲区
/// </summary>
private readonly SFML.Graphics.VertexArray _selectedBackgroundVertices = new(SFML.Graphics.PrimitiveType.Quads, 4); // XXX: 暂时未使用 Dispose 释放
/// <summary>
/// 预览画面坐标轴颜色
/// </summary>
@@ -78,57 +89,64 @@ namespace SpineViewer.ViewModels
public uint ResolutionX
{
get => _renderer.Resolution.X;
set => SetProperty(_renderer.Resolution.X, value, _renderer, (r, v) => r.Resolution = new(v, r.Resolution.Y));
set => SetProperty(_renderer.Resolution.X, value, v => _renderer.Resolution = new(v, _renderer.Resolution.Y));
}
public uint ResolutionY
{
get => _renderer.Resolution.Y;
set => SetProperty(_renderer.Resolution.Y, value, _renderer, (r, v) => r.Resolution = new(r.Resolution.X, v));
set => SetProperty(_renderer.Resolution.Y, value, v => _renderer.Resolution = new(_renderer.Resolution.X, v));
}
public float CenterX
{
get => _renderer.Center.X;
set => SetProperty(_renderer.Center.X, value, _renderer, (r, v) => r.Center = new(v, r.Center.Y));
set => SetProperty(_renderer.Center.X, value, v => _renderer.Center = new(v, _renderer.Center.Y));
}
public float CenterY
{
get => _renderer.Center.Y;
set => SetProperty(_renderer.Center.Y, value, _renderer, (r, v) => r.Center = new(r.Center.X, v));
set => SetProperty(_renderer.Center.Y, value, v => _renderer.Center = new(_renderer.Center.X, v));
}
public float Zoom
{
get => _renderer.Zoom;
set => SetProperty(_renderer.Zoom, value, _renderer, (r, v) => r.Zoom = value);
set => SetProperty(_renderer.Zoom, value, v => _renderer.Zoom = value);
}
public float Rotation
{
get => _renderer.Rotation;
set => SetProperty(_renderer.Rotation, value, _renderer, (r, v) => r.Rotation = value);
set => SetProperty(_renderer.Rotation, value, v => _renderer.Rotation = value);
}
public bool FlipX
{
get => _renderer.FlipX;
set => SetProperty(_renderer.FlipX, value, _renderer, (r, v) => r.FlipX = value);
set => SetProperty(_renderer.FlipX, value, v => _renderer.FlipX = value);
}
public bool FlipY
{
get => _renderer.FlipY;
set => SetProperty(_renderer.FlipY, value, _renderer, (r, v) => r.FlipY = value);
set => SetProperty(_renderer.FlipY, value, v => _renderer.FlipY = value);
}
public uint MaxFps
{
get => _renderer.MaxFps;
set => SetProperty(_renderer.MaxFps, value, _renderer, (r, v) => r.MaxFps = value);
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;
@@ -139,7 +157,7 @@ namespace SpineViewer.ViewModels
public Color BackgroundColor
{
get => Color.FromRgb(_backgroundColor.R, _backgroundColor.G, _backgroundColor.B);
set => SetProperty(BackgroundColor, value, this, (m, v) => m._backgroundColor = new(value.R, value.G, value.B));
set => SetProperty(BackgroundColor, value, v => _backgroundColor = new(value.R, value.G, value.B));
}
private SFML.Graphics.Color _backgroundColor = new(105, 105, 105);
@@ -182,15 +200,15 @@ namespace SpineViewer.ViewModels
});
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;
@@ -379,8 +397,20 @@ namespace SpineViewer.ViewModels
if (_cancelToken?.IsCancellationRequested ?? true) break; // 提前中止
sp.Update(0); // 避免物理效果出现问题
sp.Update(delta);
sp.Update(delta * _speed);
// 为选中对象绘制一个半透明背景
if (sp.IsSelected)
{
var rc = sp.GetCurrentBounds().ToFloatRect();
_selectedBackgroundVertices[0] = new(new(rc.Left, rc.Top), _selectedBackgroundColor);
_selectedBackgroundVertices[1] = new(new(rc.Left + rc.Width, rc.Top), _selectedBackgroundColor);
_selectedBackgroundVertices[2] = new(new(rc.Left + rc.Width, rc.Top + rc.Height), _selectedBackgroundColor);
_selectedBackgroundVertices[3] = new(new(rc.Left, rc.Top + rc.Height), _selectedBackgroundColor);
_renderer.Draw(_selectedBackgroundVertices);
}
// 仅在预览画面临时启用调试模式
sp.EnableDebug = true;
_renderer.Draw(sp);
sp.EnableDebug = false;
@@ -401,5 +431,43 @@ namespace SpineViewer.ViewModels
_renderer.SetActive(false);
}
}
public RendererWorkspaceConfigModel WorkspaceConfig
{
// TODO: 背景图片
get
{
return new()
{
ResolutionX = ResolutionX,
ResolutionY = ResolutionY,
CenterX = CenterX,
CenterY = CenterY,
Zoom = Zoom,
Rotation = Rotation,
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;
Speed = value.Speed;
ShowAxis = value.ShowAxis;
BackgroundColor = value.BackgroundColor;
}
}
}
}

View File

@@ -6,6 +6,7 @@ using SpineViewer.Extensions;
using SpineViewer.Models;
using SpineViewer.Resources;
using SpineViewer.Services;
using SpineViewer.Utils;
using SpineViewer.ViewModels.Exporters;
using System;
using System.Collections;
@@ -17,7 +18,7 @@ using System.Threading.Tasks;
using System.Windows;
using System.Windows.Shell;
namespace SpineViewer.ViewModels
namespace SpineViewer.ViewModels.MainWindow
{
public class SpineObjectListViewModel : ObservableObject
{
@@ -100,7 +101,7 @@ namespace SpineViewer.ViewModels
private void AddSpineObject_Execute()
{
throw new NotImplementedException();
MessagePopupService.Info("Not Implemented, please drag files into here or add them from clipboard :)");
}
/// <summary>
@@ -111,7 +112,7 @@ namespace SpineViewer.ViewModels
private void RemoveSpineObject_Execute(IList? args)
{
if (args is null) return;
if (!RemoveSpineObject_CanExecute(args)) return;
if (args.Count > 1)
{
@@ -121,7 +122,7 @@ namespace SpineViewer.ViewModels
lock (_spineObjectModels.Lock)
{
// XXX: 这里必须要浅拷贝一次, 不能直接对会被修改的绑定数据 args 进行 foreach 遍历
// NOTE: 这里必须要浅拷贝一次, 不能直接对会被修改的绑定数据 args 进行 foreach 遍历
foreach (var sp in args.Cast<SpineObjectModel>().ToArray())
{
_spineObjectModels.Remove(sp);
@@ -137,6 +138,121 @@ namespace SpineViewer.ViewModels
return true;
}
/// <summary>
/// 从剪贴板文件列表添加模型
/// </summary>
public RelayCommand Cmd_AddSpineObjectFromClipboard => _cmd_AddSpineObjectFromClipboard ??= new(AddSpineObjectFromClipboard_Execute);
private RelayCommand? _cmd_AddSpineObjectFromClipboard;
private void AddSpineObjectFromClipboard_Execute()
{
if (!Clipboard.ContainsFileDropList()) return;
AddSpineObjectFromFileList(Clipboard.GetFileDropList().Cast<string>().ToArray());
}
/// <summary>
/// 重新加载模型
/// </summary>
public RelayCommand<IList?> Cmd_ReloadSpineObject => _cmd_ReloadSpineObject ??= new(ReloadSpineObject_Execute, ReloadSpineObject_CanExecute);
private RelayCommand<IList?>? _cmd_ReloadSpineObject;
private void ReloadSpineObject_Execute(IList? args)
{
if (!ReloadSpineObject_CanExecute(args)) return;
if (args.Count <= 1)
{
lock (_spineObjectModels.Lock)
{
var sp = (SpineObjectModel)args[0];
var idx = _spineObjectModels.IndexOf(sp);
if (idx < 0) return;
try
{
var spNew = new SpineObjectModel(sp.SkelPath, sp.AtlasPath);
spNew.ObjectConfig = sp.ObjectConfig;
_spineObjectModels[idx] = spNew;
sp.Dispose();
}
catch (Exception ex)
{
_logger.Error("Failed to reload spine {0}, {1}", sp.SkelPath, ex.Message);
_logger.Trace(ex.ToString());
}
}
}
else
{
ProgressService.RunAsync((pr, ct) => ReloadSpineObjectsTask(
args.Cast<SpineObjectModel>().ToArray(), pr, ct),
AppResource.Str_ReloadSpineObjectsTitle
);
}
}
private bool ReloadSpineObject_CanExecute(IList? args)
{
if (args is null) return false;
if (args.Count <= 0) return false;
return true;
}
private void ReloadSpineObjectsTask(SpineObjectModel[] spines, IProgressReporter reporter, CancellationToken ct)
{
int totalCount = spines.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 sp = spines[i];
reporter.ProgressText = $"[{i}/{totalCount}] {sp.Name}";
lock (_spineObjectModels.Lock)
{
var idx = _spineObjectModels.IndexOf(sp);
if (idx >= 0)
{
try
{
var spNew = new SpineObjectModel(sp.SkelPath, sp.AtlasPath);
spNew.ObjectConfig = sp.ObjectConfig;
_spineObjectModels[idx] = spNew;
sp.Dispose();
success++;
}
catch (Exception ex)
{
error++;
_logger.Error("Failed to reload spine {0}, {1}", sp.SkelPath, ex.Message);
_logger.Trace(ex.ToString());
}
}
}
reporter.Done = i + 1;
reporter.ProgressText = $"[{i + 1}/{totalCount}] {sp.Name}";
_vmMain.ProgressValue = (i + 1f) / totalCount;
}
_vmMain.ProgressState = TaskbarItemProgressState.None;
if (error > 0)
_logger.Warn("Batch reload {0} successfully, {1} failed", success, error);
else
_logger.Info("{0} skel reloaded successfully", success);
_logger.LogCurrentProcessMemoryUsage();
}
/// <summary>
/// 模型上移一位
/// </summary>
@@ -145,8 +261,7 @@ namespace SpineViewer.ViewModels
private void MoveUpSpineObject_Execute(IList? args)
{
if (args is null) return;
if (args.Count != 1) return;
if (!MoveUpSpineObject_CanExecute(args)) return;
var sp = (SpineObjectModel)args[0];
lock (_spineObjectModels.Lock)
{
@@ -171,8 +286,7 @@ namespace SpineViewer.ViewModels
private void MoveDownSpineObject_Execute(IList? args)
{
if (args is null) return;
if (args.Count != 1) return;
if (!MoveDownSpineObject_CanExecute(args)) return;
var sp = (SpineObjectModel)args[0];
lock (_spineObjectModels.Lock)
{
@@ -189,18 +303,6 @@ namespace SpineViewer.ViewModels
return true;
}
/// <summary>
/// 从剪贴板文件列表添加模型
/// </summary>
public RelayCommand Cmd_AddSpineObjectFromClipboard => _cmd_AddSpineObjectFromClipboard ??= new(AddSpineObjectFromClipboard_Execute);
private RelayCommand? _cmd_AddSpineObjectFromClipboard;
private void AddSpineObjectFromClipboard_Execute()
{
if (!Clipboard.ContainsFileDropList()) return;
AddSpineObjectFromFileList(Clipboard.GetFileDropList().Cast<string>().ToArray());
}
/// <summary>
/// 复制模型参数
/// </summary>
@@ -209,10 +311,9 @@ namespace SpineViewer.ViewModels
private void CopySpineObjectConfig_Execute(IList? args)
{
if (args is null) return;
if (args.Count != 1) return;
if (!CopySpineObjectConfig_CanExecute(args)) return;
var sp = (SpineObjectModel)args[0];
_copiedSpineObjectConfigModel = sp.Dump();
_copiedSpineObjectConfigModel = sp.ObjectConfig;
_logger.Info("Copy config from model: {0}", sp.Name);
}
@@ -231,12 +332,10 @@ namespace SpineViewer.ViewModels
private void ApplySpineObjectConfig_Execute(IList? args)
{
if (_copiedSpineObjectConfigModel is null) return;
if (args is null) return;
if (args.Count <= 0) return;
if (!ApplySpineObjectConfig_CanExecute(args)) return;
foreach (SpineObjectModel sp in args)
{
sp.Load(_copiedSpineObjectConfigModel);
sp.ObjectConfig = _copiedSpineObjectConfigModel;
_logger.Info("Apply config to model: {0}", sp.Name);
}
}
@@ -249,6 +348,51 @@ namespace SpineViewer.ViewModels
return true;
}
public RelayCommand<IList?> Cmd_ApplySpineObjectConfigFromFile => _cmd_ApplySpineObjectConfigFromFile ??= new(ApplySpineObjectConfigFromFile_Execute, ApplySpineObjectConfigFromFile_CanExecute);
private RelayCommand<IList?>? _cmd_ApplySpineObjectConfigFromFile;
private void ApplySpineObjectConfigFromFile_Execute(IList? args)
{
if (!ApplySpineObjectConfigFromFile_CanExecute(args)) return;
if (!DialogService.ShowOpenJsonDialog(out var fileName)) return;
if (JsonHelper.Deserialize<SpineObjectConfigModel>(fileName, out var config))
{
foreach (SpineObjectModel sp in args)
{
sp.ObjectConfig = config;
_logger.Info("Apply config to model: {0}", sp.Name);
}
}
}
private bool ApplySpineObjectConfigFromFile_CanExecute(IList? args)
{
if (args is null) return false;
if (args.Count <= 0) return false;
return true;
}
public RelayCommand<IList?> Cmd_SaveSpineObjectConfigToFile => _cmd_SaveSpineObjectConfigToFile ??= new(SaveSpineObjectConfigToFile_Execute, SaveSpineObjectConfigToFile_CanExecute);
private RelayCommand<IList?>? _cmd_SaveSpineObjectConfigToFile;
private void SaveSpineObjectConfigToFile_Execute(IList? args)
{
if (!SaveSpineObjectConfigToFile_CanExecute(args)) return;
var sp = (SpineObjectModel)args[0];
var config = sp.ObjectConfig;
string fileName = $"{Path.ChangeExtension(Path.GetFileName(sp.SkelPath), ".jcfg")}";
if (!DialogService.ShowSaveJsonDialog(ref fileName, sp.AssetsDir)) return;
JsonHelper.Serialize(sp.ObjectConfig, fileName);
}
private bool SaveSpineObjectConfigToFile_CanExecute(IList? args)
{
if (args is null) return false;
if (args.Count != 1) return false;
return true;
}
/// <summary>
/// 从路径列表添加对象
/// </summary>
@@ -289,17 +433,7 @@ namespace SpineViewer.ViewModels
}
else if (validPaths.Count > 0)
{
var skelPath = validPaths[0];
try
{
var sp = new SpineObjectModel(skelPath);
lock (_spineObjectModels.Lock) _spineObjectModels.Add(sp);
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to load: {0}, {1}", skelPath, ex.Message);
}
AddSpineObject(validPaths[0]);
_logger.LogCurrentProcessMemoryUsage();
}
}
@@ -326,18 +460,10 @@ namespace SpineViewer.ViewModels
var skelPath = paths[i];
reporter.ProgressText = $"[{i}/{totalCount}] {skelPath}";
try
{
var sp = new SpineObjectModel(skelPath);
lock (_spineObjectModels.Lock) _spineObjectModels.Add(sp);
if (AddSpineObject(skelPath))
success++;
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to load: {0}, {1}", skelPath, ex.Message);
else
error++;
}
reporter.Done = i + 1;
reporter.ProgressText = $"[{i + 1}/{totalCount}] {skelPath}";
@@ -352,5 +478,132 @@ namespace SpineViewer.ViewModels
_logger.LogCurrentProcessMemoryUsage();
}
/// <summary>
/// 安全地在末尾添加一个模型, 发生错误会输出日志
/// </summary>
/// <returns>是否添加成功</returns>
private bool AddSpineObject(string skelPath, string? atlasPath = null)
{
try
{
var sp = new SpineObjectModel(skelPath, atlasPath);
lock (_spineObjectModels.Lock) _spineObjectModels.Add(sp);
return true;
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to load: {0}, {1}", skelPath, ex.Message);
}
return false;
}
private bool AddSpineObject(SpineObjectWorkspaceConfigModel cfg)
{
try
{
var sp = new SpineObjectModel(cfg);
lock (_spineObjectModels.Lock) _spineObjectModels.Add(sp);
return true;
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to load: {0}, {1}", cfg.SkelPath, ex.Message);
}
return false;
}
public List<SpineObjectWorkspaceConfigModel> LoadedSpineObjects
{
get
{
List<SpineObjectWorkspaceConfigModel> loadedSpineObjects = [];
lock (_spineObjectModels.Lock)
{
foreach (var sp in _spineObjectModels)
{
loadedSpineObjects.Add(sp.WorkspaceConfig);
}
}
return loadedSpineObjects;
}
set
{
AddSpineObjectFromWorkspaceList(value);
}
}
private void AddSpineObjectFromWorkspaceList(List<SpineObjectWorkspaceConfigModel> models)
{
lock (_spineObjectModels.Lock)
{
var spines = _spineObjectModels.ToArray();
_spineObjectModels.Clear();
foreach (var sp in spines)
{
sp.Dispose();
}
}
if (models.Count > 1)
{
ProgressService.RunAsync((pr, ct) => AddSpineObjectFromWorkspaceListTask(
models, pr, ct),
AppResource.Str_AddSpineObjectsTitle
);
}
else if (models.Count > 0)
{
AddSpineObject(models[0]);
_logger.LogCurrentProcessMemoryUsage();
}
}
private void AddSpineObjectFromWorkspaceListTask(List<SpineObjectWorkspaceConfigModel> models, IProgressReporter reporter, CancellationToken ct)
{
int totalCount = models.Count;
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 cfg = models[i];
reporter.ProgressText = $"[{i}/{totalCount}] {cfg}";
if (AddSpineObject(cfg))
success++;
else
error++;
reporter.Done = i + 1;
reporter.ProgressText = $"[{i + 1}/{totalCount}] {cfg}";
_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();
// 从工作区加载需要同步一次时间轴
lock (_spineObjectModels.Lock)
{
foreach (var sp in _spineObjectModels)
sp.ResetAnimationsTime();
}
}
}
}

View File

@@ -9,7 +9,7 @@ using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows;
namespace SpineViewer.ViewModels
namespace SpineViewer.ViewModels.MainWindow
{
public class SpineObjectTabViewModel : ObservableObject
{
@@ -49,15 +49,15 @@ namespace SpineViewer.ViewModels
IEnumerable<string> commonSkinNames = _selectedObjects[0].Skins;
foreach (var obj in _selectedObjects.Skip(1)) commonSkinNames = commonSkinNames.Intersect(obj.Skins);
foreach (var name in commonSkinNames) _skins.Add(new(name, _selectedObjects));
foreach (var name in commonSkinNames.Order()) _skins.Add(new(name, _selectedObjects));
IEnumerable<string> commonSlotNames = _selectedObjects[0].SlotAttachments.Keys;
foreach (var obj in _selectedObjects.Skip(1)) commonSlotNames = commonSlotNames.Intersect(obj.SlotAttachments.Keys);
foreach (var name in commonSlotNames) _slots.Add(new(name, _selectedObjects));
foreach (var name in commonSlotNames.Order()) _slots.Add(new(name, _selectedObjects));
IEnumerable<int> commonTrackIndices = _selectedObjects[0].GetTrackIndices();
foreach (var obj in _selectedObjects.Skip(1)) commonTrackIndices = commonTrackIndices.Intersect(obj.GetTrackIndices());
foreach (var idx in commonTrackIndices) _animationTracks.Add(new(idx, _selectedObjects));
foreach (var idx in commonTrackIndices.Order()) _animationTracks.Add(new(idx, _selectedObjects));
}
OnPropertyChanged();
@@ -611,7 +611,7 @@ namespace SpineViewer.ViewModels
// 但是目前无法识别是否增加了轨道, 因此总是重建列表
// 由于某些原因, 直接使用 Clear 会和 UI 逻辑冲突产生报错, 因此需要放到 Dispatcher 里延迟执行
App.Current.Dispatcher.BeginInvoke(
Application.Current.Dispatcher.BeginInvoke(
() =>
{
_animationTracks.Clear();
@@ -786,7 +786,7 @@ namespace SpineViewer.ViewModels
{
get
{
/// XXX: 空轨道和多选不相同都会返回 null
// XXX: 空轨道和多选不相同都会返回 null
if (_spines.Length <= 0) return null;
var val = _spines[0].GetAnimation(_trackIndex);
if (_spines.Skip(1).Any(it => it.GetAnimation(_trackIndex) != val)) return null;

View File

@@ -63,6 +63,7 @@ namespace SpineViewer.ViewModels
private void Cancel_Execute()
{
if (!Cancel_CanExecute()) return;
if (!MessagePopupService.Quest(AppResource.Str_CancelQuest)) return;
_cts.Cancel();
Cmd_Cancel.NotifyCanExecuteChanged();

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

@@ -44,7 +44,7 @@ namespace SpineViewer.Views.ExporterDialogs
private void ButtonSelectOutputDir_Click(object sender, RoutedEventArgs e)
{
if (OpenFolderService.OpenFolder(out var selectedPath))
if (DialogService.ShowOpenFolderDialog(out var selectedPath))
{
var vm = (CustomFFmpegExporterViewModel)DataContext;
vm.OutputDir = selectedPath;

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 VideoFormats}"/>
<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

@@ -44,7 +44,7 @@ namespace SpineViewer.Views.ExporterDialogs
private void ButtonSelectOutputDir_Click(object sender, RoutedEventArgs e)
{
if (OpenFolderService.OpenFolder(out var selectedPath))
if (DialogService.ShowOpenFolderDialog(out var selectedPath))
{
var vm = (FFmpegVideoExporterViewModel)DataContext;
vm.OutputDir = selectedPath;

View File

@@ -110,7 +110,7 @@
<!-- 图像格式 -->
<Label Grid.Row="9" Grid.Column="0" Content="{DynamicResource Str_ImageFormat}"/>
<ComboBox Grid.Row="9" Grid.Column="1" SelectedItem="{Binding Format}" ItemsSource="{Binding FrameFormats}"/>
<ComboBox Grid.Row="9" Grid.Column="1" SelectedItem="{Binding Format}" ItemsSource="{Binding FrameFormatOptions}"/>
<!-- 图像质量 -->
<Label Grid.Row="10" Grid.Column="0" Content="{DynamicResource Str_ImageQuality}" ToolTip="{DynamicResource Str_ImageQualityTooltip}"/>

View File

@@ -44,7 +44,7 @@ namespace SpineViewer.Views.ExporterDialogs
private void ButtonSelectOutputDir_Click(object sender, RoutedEventArgs e)
{
if (OpenFolderService.OpenFolder(out var selectedPath))
if (DialogService.ShowOpenFolderDialog(out var selectedPath))
{
var vm = (FrameExporterViewModel)DataContext;
vm.OutputDir = selectedPath;

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

@@ -44,7 +44,7 @@ namespace SpineViewer.Views.ExporterDialogs
private void ButtonSelectOutputDir_Click(object sender, RoutedEventArgs e)
{
if (OpenFolderService.OpenFolder(out var selectedPath))
if (DialogService.ShowOpenFolderDialog(out var selectedPath))
{
var vm = (FrameSequenceExporterViewModel)DataContext;
vm.OutputDir = selectedPath;

View File

@@ -5,8 +5,8 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:hc="https://handyorg.github.io/handycontrol"
xmlns:vm="clr-namespace:SpineViewer.ViewModels"
xmlns:ext="clr-namespace:SpineViewer.Extensions"
xmlns:vm="clr-namespace:SpineViewer.ViewModels.MainWindow"
xmlns:utils="clr-namespace:SpineViewer.Utils"
xmlns:SFMLRenderer="clr-namespace:SFMLRenderer;assembly=SFMLRenderer"
mc:Ignorable="d"
Title="{Binding Title}"
@@ -35,7 +35,7 @@
</Trigger>
</Style.Triggers>
</Style>
<ext:StringFormatMultiValueConverter x:Key="StrFmtCvter"/>
<utils:StringFormatMultiValueConverter x:Key="StrFmtCvter"/>
</Window.Resources>
<Window.TaskbarItemInfo>
@@ -56,9 +56,10 @@
<!-- 菜单 -->
<Menu x:Name="_mainMenu">
<MenuItem Header="{DynamicResource Str_File}">
<MenuItem Header="{DynamicResource Str_Open}" InputGestureText="Ctrl+O"/>
<MenuItem Header="{DynamicResource Str_OpenWorkspace}" Command="{Binding Cmd_OpenWorkspace}"/>
<MenuItem Header="{DynamicResource Str_SaveWorkspace}" Command="{Binding Cmd_SaveWorkspace}"/>
<Separator/>
<MenuItem Header="{DynamicResource Str_Preference}"/>
<MenuItem Header="{DynamicResource Str_PreferenceWithDots}" Command="{Binding PreferenceViewModel.Cmd_ShowPreferenceDialog}"/>
<!--<MenuItem Header="{DynamicResource Str_Exit}" InputGestureText="Alt+F4"/>-->
</MenuItem>
<!--<MenuItem Header="{DynamicResource Str_Tool}"/>-->
@@ -244,11 +245,11 @@
</i:Interaction.Triggers>
<ListView.InputBindings>
<KeyBinding Gesture="Ctrl+O" Command="{Binding Cmd_AddSpineObject}"/>
<KeyBinding Gesture="Delete" Command="{Binding Cmd_RemoveSpineObject}" CommandParameter="{Binding SelectedItems, ElementName=_spinesListView}"/>
<KeyBinding Gesture="Ctrl+V" Command="{Binding Cmd_AddSpineObjectFromClipboard}"/>
<KeyBinding Gesture="Ctrl+R" Command="{Binding Cmd_ReloadSpineObject}" CommandParameter="{Binding SelectedItems, ElementName=_spinesListView}"/>
<KeyBinding Gesture="Alt+W" Command="{Binding Cmd_MoveUpSpineObject}" CommandParameter="{Binding SelectedItems, ElementName=_spinesListView}"/>
<KeyBinding Gesture="Alt+S" Command="{Binding Cmd_MoveDownSpineObject}" CommandParameter="{Binding SelectedItems, ElementName=_spinesListView}"/>
<KeyBinding Gesture="Ctrl+V" Command="{Binding Cmd_AddSpineObjectFromClipboard}"/>
<KeyBinding Gesture="Ctrl+Shift+C" Command="{Binding Cmd_CopySpineObjectConfig}" CommandParameter="{Binding SelectedItems, ElementName=_spinesListView}"/>
<KeyBinding Gesture="Ctrl+Shift+V" Command="{Binding Cmd_ApplySpineObjectConfig}" CommandParameter="{Binding SelectedItems, ElementName=_spinesListView}"/>
</ListView.InputBindings>
@@ -256,12 +257,18 @@
<ListView.ContextMenu>
<ContextMenu>
<MenuItem Header="{DynamicResource Str_AddSpineObject}"
InputGestureText="Ctrl+O"
Command="{Binding Cmd_AddSpineObject}"/>
<MenuItem Header="{DynamicResource Str_RemoveSpineObject}"
InputGestureText="Delete"
Command="{Binding Cmd_RemoveSpineObject}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<MenuItem Header="{DynamicResource Str_AddSpineObjectFromClipboard}"
InputGestureText="Ctrl+V"
Command="{Binding Cmd_AddSpineObjectFromClipboard}"/>
<MenuItem Header="{DynamicResource Str_Reload}"
InputGestureText="Ctrl+R"
Command="{Binding Cmd_ReloadSpineObject}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<Separator/>
<MenuItem Header="{DynamicResource Str_MoveUpSpineObject}"
InputGestureText="Alt+W"
@@ -272,10 +279,6 @@
Command="{Binding Cmd_MoveDownSpineObject}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<Separator/>
<MenuItem Header="{DynamicResource Str_AddSpineObjectFromClipboard}"
InputGestureText="Ctrl+V"
Command="{Binding Cmd_AddSpineObjectFromClipboard}"/>
<Separator/>
<MenuItem Header="{DynamicResource Str_CopySpineObjectConfig}"
InputGestureText="Ctrl+Shift+C"
Command="{Binding Cmd_CopySpineObjectConfig}"
@@ -284,9 +287,12 @@
InputGestureText="Ctrl+Shift+V"
Command="{Binding Cmd_ApplySpineObjectConfig}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<Separator/>
<MenuItem Header="{DynamicResource Str_ApplySpineObjectConfigFromFile}"/>
<MenuItem Header="{DynamicResource Str_SaveSpineObjectConfigToFile}"/>
<MenuItem Header="{DynamicResource Str_ApplySpineObjectConfigFromFile}"
Command="{Binding Cmd_ApplySpineObjectConfigFromFile}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<MenuItem Header="{DynamicResource Str_SaveSpineObjectConfigToFile}"
Command="{Binding Cmd_SaveSpineObjectConfigToFile}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<Separator/>
<MenuItem Header="{DynamicResource Str_Export}">
<MenuItem Header="{DynamicResource Str_ExportFrame}"
@@ -726,9 +732,9 @@
<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_RenderSelectedOnly}"/>
<ToggleButton Grid.Row="9" Grid.Column="1" IsChecked="{Binding RenderSelectedOnly}"/>
<!-- 播放速度 -->
<Label Grid.Row="9" Grid.Column="0" Content="{DynamicResource Str_PlaySpeed}"/>
<TextBox Grid.Row="9" Grid.Column="1" Text="{Binding Speed}"/>
<!-- 显示坐标轴 -->
<Label Grid.Row="10" Grid.Column="0" Content="{DynamicResource Str_ShowAxis}"/>

View File

@@ -5,7 +5,7 @@ using NLog.Targets;
using Spine;
using SpineViewer.Natives;
using SpineViewer.Resources;
using SpineViewer.ViewModels;
using SpineViewer.ViewModels.MainWindow;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics;
@@ -61,6 +61,9 @@ public partial class MainWindow : Window
vm.FlipY = true;
vm.MaxFps = 30;
vm.StartRender();
// 加载首选项
_vm.PreferenceViewModel.LoadPreference();
}
private void MainWindow_Closed(object? sender, EventArgs e)

View File

@@ -1,12 +0,0 @@
<Window x:Class="SpineViewer.Views.MessageBoxWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:SpineViewer.Views"
mc:Ignorable="d"
Title="MessageBoxWindow" Height="450" Width="800">
<Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,162 @@
<Window x:Class="SpineViewer.Views.PreferenceDialog"
xmlns:hc="https://handyorg.github.io/handycontrol"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:SpineViewer.Views"
xmlns:models="clr-namespace:SpineViewer.Models"
xmlns:vm="clr-namespace:SpineViewer.ViewModels.MainWindow"
d:DataContext="{d:DesignInstance Type=models:PreferenceModel}"
mc:Ignorable="d"
Title="{DynamicResource Str_Preference}"
Height="650"
Width="600"
ShowInTaskbar="False"
WindowStartupLocation="CenterOwner">
<DockPanel>
<Border DockPanel.Dock="Bottom">
<WrapPanel HorizontalAlignment="Center">
<WrapPanel.Resources>
<Style TargetType="{x:Type Button}" BasedOn="{StaticResource ButtonDefault}">
<Setter Property="Margin" Value="5"/>
<Setter Property="Width" Value="100"/>
</Style>
</WrapPanel.Resources>
<Button Content="{DynamicResource Str_OK}" Click="ButtonOK_Click"/>
<Button Content="{DynamicResource Str_Cancel}" Click="ButtonCancel_Click"/>
</WrapPanel>
</Border>
<Border>
<Border.Resources>
<Style TargetType="{x:Type Label}" BasedOn="{StaticResource LabelDefault}">
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="HorizontalContentAlignment" Value="Right"/>
</Style>
<Style TargetType="{x:Type TextBox}" BasedOn="{StaticResource TextBoxBaseStyle}">
<Setter Property="HorizontalContentAlignment" Value="Right"/>
</Style>
<Style TargetType="{x:Type ComboBox}" BasedOn="{StaticResource ComboBoxBaseStyle}">
<Setter Property="HorizontalContentAlignment" Value="Right"/>
</Style>
<Style TargetType="{x:Type ToggleButton}" BasedOn="{StaticResource MyToggleButton}">
<Setter Property="HorizontalAlignment" Value="Right"/>
</Style>
<Style TargetType="{x:Type GroupBox}" BasedOn="{StaticResource GroupBoxTab}">
<Setter Property="BorderBrush" Value="LightGray"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="hc:TitleElement.Background" Value="Transparent"/>
<Setter Property="Margin" Value="0 5 0 10"/>
</Style>
</Border.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Grid.IsSharedSizeScope="True">
<GroupBox Header="{DynamicResource Str_TextureLoadPreference}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="Col1"/>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Label Grid.Row="0" Grid.Column="0" Content="{DynamicResource Str_ForcePremul}" ToolTip="{DynamicResource Str_ForcePremulTooltip}"/>
<ToggleButton Grid.Row="0" Grid.Column="1" IsChecked="{Binding ForcePremul}" ToolTip="{DynamicResource Str_ForcePremulTooltip}"/>
<Label Grid.Row="1" Grid.Column="0" Content="{DynamicResource Str_ForceNearest}"/>
<ToggleButton Grid.Row="1" Grid.Column="1" IsChecked="{Binding ForceNearest}"/>
<Label Grid.Row="2" Grid.Column="0" Content="{DynamicResource Str_ForceMipmap}" ToolTip="{DynamicResource Str_ForceMipmapTooltip}"/>
<ToggleButton Grid.Row="2" Grid.Column="1" IsChecked="{Binding ForceMipmap}" ToolTip="{DynamicResource Str_ForceMipmapTooltip}"/>
</Grid>
</GroupBox>
<GroupBox Header="{DynamicResource Str_SpineLoadPreference}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="Col1"/>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Label Grid.Row="0" Grid.Column="0" Content="{DynamicResource Str_IsShown}"/>
<ToggleButton Grid.Row="0" Grid.Column="1" IsChecked="{Binding IsShown}"/>
<Label Grid.Row="1" Grid.Column="0" Content="{DynamicResource Str_UsePma}"/>
<ToggleButton Grid.Row="1" Grid.Column="1" IsChecked="{Binding UsePma}"/>
<Label Grid.Row="2" Grid.Column="0" Content="{DynamicResource Str_DebugTexture}"/>
<ToggleButton Grid.Row="2" Grid.Column="1" IsChecked="{Binding DebugTexture}"/>
<Label Grid.Row="3" Grid.Column="0" Content="{DynamicResource Str_DebugBounds}"/>
<ToggleButton Grid.Row="3" Grid.Column="1" IsChecked="{Binding DebugBounds}"/>
<Label Grid.Row="4" Grid.Column="0" Content="{DynamicResource Str_DebugBones}"/>
<ToggleButton Grid.Row="4" Grid.Column="1" IsChecked="{Binding DebugBones}"/>
<Label Grid.Row="5" Grid.Column="0" Content="{DynamicResource Str_DebugRegions}"/>
<ToggleButton Grid.Row="5" Grid.Column="1" IsChecked="{Binding DebugRegions}"/>
<Label Grid.Row="6" Grid.Column="0" Content="{DynamicResource Str_DebugMeshHulls}"/>
<ToggleButton Grid.Row="6" Grid.Column="1" IsChecked="{Binding DebugMeshHulls}"/>
<Label Grid.Row="7" Grid.Column="0" Content="{DynamicResource Str_DebugMeshes}"/>
<ToggleButton Grid.Row="7" Grid.Column="1" IsChecked="{Binding DebugMeshes}"/>
<Label Grid.Row="8" Grid.Column="0" Content="{DynamicResource Str_DebugClippings}"/>
<ToggleButton Grid.Row="8" Grid.Column="1" IsChecked="{Binding DebugClippings}"/>
<!--<Label Grid.Row="9" Grid.Column="0" Content="{DynamicResource Str_DebugBoundingBoxes}"/>
<ToggleButton Grid.Row="9" Grid.Column="1" IsChecked="{Binding DebugBoundingBoxes}"/>
<Label Grid.Row="10" Grid.Column="0" Content="{DynamicResource Str_DebugPaths}"/>
<ToggleButton Grid.Row="10" Grid.Column="1" IsChecked="{Binding DebugPaths}"/>
<Label Grid.Row="11" Grid.Column="0" Content="{DynamicResource Str_DebugPoints}"/>
<ToggleButton Grid.Row="11" Grid.Column="1" IsChecked="{Binding DebugPoints}"/>-->
</Grid>
</GroupBox>
<GroupBox Header="{DynamicResource Str_AppPreference}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="Col1"/>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Label Grid.Row="0" Grid.Column="0" Content="{DynamicResource Str_RenderSelectedOnly}"/>
<ToggleButton Grid.Row="0" Grid.Column="1" IsChecked="{Binding RenderSelectedOnly}"/>
<Label Grid.Row="1" Grid.Column="0" Content="{DynamicResource Str_Language}"/>
<ComboBox Grid.Row="1" Grid.Column="1"
SelectedItem="{Binding AppLanguage}"
ItemsSource="{x:Static vm:PreferenceViewModel.AppLanguageOptions}"/>
</Grid>
</GroupBox>
</StackPanel>
</ScrollViewer>
</Border>
</DockPanel>
</Window>

View File

@@ -1,4 +1,6 @@
using System;
using SpineViewer.Services;
using SpineViewer.ViewModels.Exporters;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
@@ -15,13 +17,23 @@ using System.Windows.Shapes;
namespace SpineViewer.Views
{
/// <summary>
/// MessageBoxWindow.xaml 的交互逻辑
/// PreferenceDialog.xaml 的交互逻辑
/// </summary>
public partial class MessageBoxWindow : Window
public partial class PreferenceDialog : Window
{
public MessageBoxWindow()
public PreferenceDialog()
{
InitializeComponent();
}
private void ButtonOK_Click(object sender, RoutedEventArgs e)
{
DialogResult = true;
}
private void ButtonCancel_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
}
}
}

79
SpineViewer/app.manifest Normal file
View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- UAC 清单选项
如果想要更改 Windows 用户帐户控制级别,请使用
以下节点之一替换 requestedExecutionLevel 节点。
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
指定 requestedExecutionLevel 元素将禁用文件和注册表虚拟化。
如果你的应用程序需要此虚拟化来实现向后兼容性,则移除此
元素。
-->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- 设计此应用程序与其一起工作且已针对此应用程序进行测试的
Windows 版本的列表。取消评论适当的元素,
Windows 将自动选择最兼容的环境。 -->
<!-- Windows Vista -->
<!--<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />-->
<!-- Windows 7 -->
<!--<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />-->
<!-- Windows 8 -->
<!--<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />-->
<!-- Windows 8.1 -->
<!--<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />-->
<!-- Windows 10 -->
<!--<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />-->
</application>
</compatibility>
<!-- 指示该应用程序可感知 DPI 且 Windows 在 DPI 较高时将不会对其进行
自动缩放。Windows Presentation Foundation (WPF)应用程序自动感知 DPI无需
选择加入。选择加入此设置的 Windows 窗体应用程序(面向 .NET Framework 4.6)还应
在其 app.config 中将 "EnableWindowsFormsHighDpiAutoResizing" 设置设置为 "true"。
将应用程序设为感知长路径。请参阅 https://docs.microsoft.com/windows/win32/fileio/maximum-file-path-limitation -->
<!--
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
-->
<!-- 启用 Windows 公共控件和对话框的主题(Windows XP 和更高版本) -->
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
</assembly>

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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 177 KiB