Compare commits

..

63 Commits

Author SHA1 Message Date
ww-rm
dc2cb61219 Merge pull request #144 from ww-rm/dev/wpf
v0.16.11
2025-11-02 22:09:34 +08:00
ww-rm
decbb10fcb 修复可能的null错误 2025-11-02 22:05:58 +08:00
ww-rm
c538fd8960 Merge pull request #143 from ww-rm/dev/wpf
v0.16.11
2025-11-02 21:38:33 +08:00
ww-rm
cc884f7f5b update to v0.16.11 2025-11-02 21:36:29 +08:00
ww-rm
dcfe48912b 修改缩放倍数 2025-11-02 21:36:00 +08:00
ww-rm
3c77365f60 update readme 2025-11-02 21:34:22 +08:00
ww-rm
baff9579e5 update changelog 2025-11-02 21:33:10 +08:00
ww-rm
44f4367f3e 增加shift切换缩放倍速 2025-11-02 21:30:08 +08:00
ww-rm
5823d58dca 调整面板顺序 2025-11-02 15:26:20 +08:00
ww-rm
7bb76d508e 移动浏览目录至用户状态 2025-11-02 15:25:43 +08:00
ww-rm
b4c7579d24 修复字体颜色错误 2025-11-02 15:07:08 +08:00
ww-rm
aafe487d96 调试标题 2025-11-02 14:28:54 +08:00
ww-rm
dc2f6a2dad 改善性能 2025-11-02 14:22:00 +08:00
ww-rm
10b691d897 增加调试标志位 2025-11-02 12:24:17 +08:00
ww-rm
50219946ec 修改缩放变化量 2025-11-02 11:20:24 +08:00
ww-rm
659f6fb690 Merge pull request #142 from ww-rm/dev/wpf
update readme
2025-10-29 23:22:06 +08:00
ww-rm
af5cb97f1a update readme 2025-10-29 23:21:10 +08:00
ww-rm
1be9e9e75f Merge pull request #141 from ww-rm/dev/wpf
update readme
2025-10-29 23:19:26 +08:00
ww-rm
b0308db977 update readme 2025-10-29 23:18:40 +08:00
ww-rm
2dbcfe4ea7 update readme 2025-10-29 23:17:51 +08:00
ww-rm
9040e02025 Merge pull request #140 from ww-rm/dev/wpf
v0.16.10
2025-10-29 21:35:34 +08:00
ww-rm
b3ba073368 完善日志 2025-10-29 21:34:30 +08:00
ww-rm
332019a667 修复文件夹无法自动创建的bug 2025-10-29 21:00:38 +08:00
ww-rm
add9cf157d 修改压缩路径 2025-10-29 20:54:35 +08:00
ww-rm
8b0ea750d8 Merge pull request #139 from ww-rm/dev/wpf
v0.16.10
2025-10-29 20:45:24 +08:00
ww-rm
733739921d update changelog 2025-10-29 20:44:20 +08:00
ww-rm
e0f46f521a update to v0.16.10 2025-10-29 20:43:43 +08:00
ww-rm
aa4245ef2a add linux release 2025-10-29 20:42:31 +08:00
ww-rm
a262538eba 增加linux条件依赖 2025-10-29 19:48:23 +08:00
ww-rm
2e4a5a75c0 修复着色器语法兼容性错误 2025-10-29 19:33:45 +08:00
ww-rm
9331656431 修改项目配置 2025-10-28 22:03:39 +08:00
ww-rm
64bc12db06 Merge pull request #136 from ww-rm/dev/wpf
v0.16.9
2025-10-27 23:51:47 +08:00
ww-rm
7a29fee641 update readme 2025-10-27 23:50:39 +08:00
ww-rm
49f95ddbb7 add readme 2025-10-27 23:48:21 +08:00
ww-rm
317ee71882 update changelog 2025-10-27 23:45:16 +08:00
ww-rm
7780fbda28 update ignore 2025-10-27 23:43:57 +08:00
ww-rm
b54c6a1777 update to v0.16.9 2025-10-27 23:43:52 +08:00
ww-rm
617157044c 增加透明度参数 2025-10-27 23:33:25 +08:00
ww-rm
29d7e8d9d8 移除依赖库 2025-10-27 22:26:33 +08:00
ww-rm
701d1fcf90 增加日志 2025-10-27 07:35:05 +08:00
ww-rm
df36d46528 增加动态进度条 2025-10-27 00:00:44 +08:00
ww-rm
3459f3af03 修复进度回调done值错误 2025-10-26 23:59:57 +08:00
ww-rm
5498508700 移除不受支持的格式 2025-10-26 23:16:40 +08:00
ww-rm
a61bb43250 增加preview命令 2025-10-26 22:14:34 +08:00
ww-rm
aace461ae0 修改方法名 2025-10-26 22:05:48 +08:00
ww-rm
c02cec9a18 修改图像质量默认值为100 2025-10-26 22:01:40 +08:00
ww-rm
31daed9e81 移除不受支持的图像格式 2025-10-26 21:49:58 +08:00
ww-rm
997d55350d 修复可能的资源泄露 2025-10-26 21:28:11 +08:00
ww-rm
cc6d1b6c00 更新注释 2025-10-26 19:35:43 +08:00
ww-rm
e14c54c3a4 调整时间轴处理顺序 2025-10-26 17:35:51 +08:00
ww-rm
5eba515eac 增加 query 命令 2025-10-26 17:31:20 +08:00
ww-rm
f878530184 重构 2025-10-26 16:30:13 +08:00
ww-rm
81d9224658 增加参数验证 2025-10-26 16:22:49 +08:00
ww-rm
9d9edb8bc4 增加 export 命令 2025-10-26 16:16:43 +08:00
ww-rm
d3b5814c6f small change 2025-10-26 15:52:47 +08:00
ww-rm
aade44cffb 增加注释 2025-10-26 15:19:09 +08:00
ww-rm
c4956b9c16 重构 2025-10-26 13:26:47 +08:00
ww-rm
7ca431b214 增加System.CommandLine库 2025-10-25 17:19:27 +08:00
ww-rm
74538ddf74 apng和mov格式参数改为枚举量类型 2025-10-25 17:04:39 +08:00
ww-rm
779500ee8e 修改ApngPred属性名为PredMethod 2025-10-25 16:47:00 +08:00
ww-rm
ee7c9e9e54 Merge pull request #132 from jayng9663/dev/wpf
Add --warmup option to control physics warmup loops
2025-10-24 23:07:13 +08:00
ww-rm
d335645dc1 remove unnecessary frame loops 2025-10-19 20:46:42 +08:00
Jay
6a17ec0397 Add --warmup option to control physics warmup loops
Create a new --warmup argument to specify the number of warmup loops for physics before export. This allows users to control how many times the animation is pre-processed to stabilize physics.
2025-10-19 04:59:34 -07:00
48 changed files with 1994 additions and 764 deletions

View File

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

2
.gitignore vendored
View File

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

View File

@@ -1,5 +1,21 @@
# CHANGELOG
## v0.16.11
- 增加 shift 切换缩放倍数
- 改善后台性能
- 修复字体显示颜色问题
- 调整浏览目录参数保存至用户状态
- 调整浏览面板至最后
## v0.16.10
- 增加 Linux 平台 CLI 工具构建
## v0.16.9
- 重构 CLI 工具
## v0.16.8
- 去除首次的最小化提示弹框

View File

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

View File

@@ -1,139 +1,154 @@
# [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)
[![Build and Release](https://github.com/ww-rm/SpineViewer/actions/workflows/dotnet-release.yml/badge.svg)](https://github.com/ww-rm/SpineViewer/actions/workflows/dotnet-release.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)
![Languages](https://img.shields.io/badge/Languages-中文%20%7C%20English%20%7C%20日本語-blue)
[中文](README.md) | [English](README.en.md)
A simple and user-friendly Spine file viewer and exporter with multi-language support (Chinese/English/Japanese).
Spine file viewer & exporter, also a dynamic wallpaper program supporting Spine animations.
![previewer](https://github.com/user-attachments/assets/697ae86f-ddf0-445d-951c-cf04f5206e40)
https://github.com/user-attachments/assets/37b6b730-088a-4352-827a-c338127a16f0
[https://github.com/user-attachments/assets/37b6b730-088a-4352-827a-c338127a16f0](https://github.com/user-attachments/assets/37b6b730-088a-4352-827a-c338127a16f0)
---
## Features
- Multiple versions of Spine files
- Batch file opening via drag-and-drop or copy-paste
- Batch preview
- List-based multi-skeleton viewing and render order management
- Multi-selection in lists for batch skeleton parameter settings
- Multi-track animation settings
- Skin and custom slot attachment settings
- Custom slot visibility
- Debug rendering
- Playback speed adjustment for view/model/track timelines
- Track alpha blending parameter settings
- Fullscreen preview
- Export to single frame, image sequence, animated GIF, or video file
- Automatic resolution batch export
- Custom export with FFmpeg
- Program parameter saving
- File extension association
- Texture images in formats other than PNG
- Launch at startup with persistent dynamic wallpaper
- Supports multiple Spine file versions (`2.1.x; 3.4.x - 4.2.-`)
- List-based multi-skeleton view with rendering order management
- Supports multi-track animations
- Supports skin/slot/attachment settings
- Debug rendering support
- Frame rate / model / track time scale adjustment
- Track alpha blending control
- Export single frame / GIF / video
- Custom export via FFmpeg
- Supports non-PNG texture formats
- Desktop dynamic wallpaper with auto-start support
- ......
### Supported Spine Versions
| Version | View & Export |
| :-----: | :------------------: |
| `2.1.x` | :white\_check\_mark: |
| `3.4.x` | :white\_check\_mark: |
| `3.5.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 under development \:rocket: \:rocket: \:rocket:
### Supported Export Formats
| 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
Download the compressed package from the [Release](https://github.com/ww-rm/SpineViewer/releases) page.
Download the compressed package from the [Releases](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.
The program requires the [.NET Desktop Runtime 8.0.x](https://dotnet.microsoft.com/download/dotnet/8.0) to be installed.
Alternatively, download the package with the `SelfContained` suffix for standalone execution.
You can also download packages with the `SelfContained` suffix, which can run independently without additional installations.
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).
Exporting GIF/MP4 or other animated/video formats requires **ffmpeg** installed locally and added to the system PATH. Download [FFmpeg for Windows](https://ffmpeg.org/download.html#build-windows) or the latest full build [ffmpeg-release-full.7z](https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full.7z).
---
## Changing Display Language
Currently, the program supports the following interface languages:
- `ZH` (Chinese)
- `EN` (English)
- `JA` (Japanese)
Change the language via the menu: **File → Preferences… → Language**, then confirm.
---
## Usage
### How to Change the Display Language
### Overview
In the menu, go to "File" -> "Preferences..." -> "Language," select your desired language, and confirm the change.
The program uses a left-right layout: the left panel contains controls, the right panel displays the preview.
### Basic Overview
The left panel contains three sub-panels:
The program is organized into a left-right layout:
- **Models**: Lists imported and rendered models. Set model parameters, rendering order, and other model-related functions here.
- **Browser**: Preview files in a folder without actually importing them. Generate WebP previews or import selected models.
- **Canvas**: Set parameters for the right-side preview display.
- **Left Panel:** Functionality panel.
- **Right Panel:** Preview display.
Most buttons, labels, or input fields show help text on hover.
The left panel includes three sub-panels:
---
- **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.
### Importing Skeletons
Hover your mouse over buttons, labels, or input fields to see help text for most UI elements.
Drag-and-drop or paste skeleton files/folders directly into the **Models** panel.
### Skeleton Import
Alternatively, use the right-click menu in the **Browser** panel to import selected items.
Drag-and-drop or paste skeleton files/directories into the Model panel.
---
Alternatively, use the right-click menu in the Browse panel to import selected items.
### Adjusting Content
### Content Adjustment
The **Models** panel supports right-click menus, some hotkeys, and batch editing via multi-selection.
The Model panel supports right-click menus, some shortcuts, and batch adjustments of model parameters through multi-selection.
Mouse interactions in the preview panel:
For preview display adjustments:
- **Left click**: select and drag models. Hold `Ctrl` for multi-selection (synchronized with the model list).
- **Right click**: drag the entire canvas.
- **Mouse wheel**: zoom in/out. Hold `Ctrl` to scale selected models together, use `Shift` to switch zoom factor.
- **Render selected only**: preview only the selected models, selection can only be changed via the left panel.
- **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.
Playback controls below the preview allow time adjustment, acting as a simple player.
The buttons below the preview display allow time adjustments, serving as a simple playback control.
---
### Content Export
### Exporting Content
Export follows the **WYSIWYG (What You See Is What You Get)** principle, meaning the preview display reflects the exported output.
Right-click on models in the list to access export options.
Use the right-click menu in the Model panel to export selected items.
Key export parameters:
Key export parameters include:
- **Output folder**: Optional. If not provided, outputs go to each models folder. Otherwise, all outputs go to the specified folder.
- **Single export**: Default exports each model separately. If enabled, all selected models are rendered together in one output.
- **Auto resolution**: Ignores preview canvas resolution; exported resolution matches the actual size of content. For animations or videos, ensures full display of the animation.
- **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.
---
### Dynamic Wallpaper
Dynamic wallpaper is implemented through desktop projection, allowing the content of the current preview to be projected onto the desktop in real time.
The dynamic wallpaper projects the current preview content to the desktop in real time.
You can enable or disable desktop projection from the program preferences or the right-click menu of the tray icon. After adjusting the model and display parameters, you can save the current configuration as a workspace file for convenient restoration later.
Enable or disable via program preferences or the tray icon menu. Save workspace files to preserve model and canvas settings.
If you want the wallpaper to stay active after startup, you can enable auto-start in the preferences and specify which workspace file should be loaded when the program launches.
Auto-start with Windows can also be enabled, along with loading a specific workspace on startup.
### 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).
### Command-line Tool
The project includes a CLI tool `SpineViewerCLI` for simple operations on a single model (querying parameters, exporting, etc.). Windows and Linux binaries are provided in Releases.
```bash
$ SpineViewerCLI -h
Description:
Root Command
Usage:
SpineViewerCLI [command] [options]
Options:
-q, --quiet Suppress console logging (quiet mode).
-?, -h, --help Show help and usage information
--version Show version information
Commands:
query <skel> Query information of single model
preview <skel> Preview a model
export <skel> Export single model
```
---
### More
Detailed instructions and usage guides can be found in the [Wiki](https://github.com/ww-rm/SpineViewer/wiki).
Report issues or bugs via [GitHub Issues](https://github.com/ww-rm/SpineViewer/issues).
---
## Acknowledgements
@@ -143,9 +158,10 @@ For detailed usage and documentation, see the [Wiki](https://github.com/ww-rm/Sp
- [HandyControl](https://github.com/HandyOrg/HandyControl)
- [NLog](https://github.com/NLog/NLog)
- [SkiaSharp](https://github.com/mono/SkiaSharp)
- [Spectre.Console](https://github.com/spectreconsole/spectre.console)
---
*If you find this project helpful, please give it a \:star: and share it with others! :)*
*If you like this project, 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

@@ -1,6 +1,6 @@
# [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)
[![Build and Release](https://github.com/ww-rm/SpineViewer/actions/workflows/dotnet-release.yml/badge.svg)](https://github.com/ww-rm/SpineViewer/actions/workflows/dotnet-release.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)
@@ -16,53 +16,19 @@ https://github.com/user-attachments/assets/37b6b730-088a-4352-827a-c338127a16f0
## 功能
- 支持多版本 spine 文件
- 支持拖拽/复制粘贴批量打开文件
- 支持批量预览
- 支持多版本 spine 文件 (`2.1.x; 3.4.x - 4.2.x`)
- 支持列表式多骨骼查看和渲染层级管理
- 支持列表多选批量设置骨骼参数
- 支持多轨道动画设置
- 支持皮肤/自定义插槽附件设置
- 支持自定义插槽可见性
- 支持多轨道动画
- 支持皮肤/插槽/附件设置
- 支持调试渲染
- 支持画面/模型/轨道时间倍速设置
- 支持设置轨道 Alpha 混合参数
- 支持全屏预览
- 支持单帧/动图/视频文件导出
- 支持自动分辨率批量导出
- 支持 FFmpeg 自定义导出
- 支持程序参数保存
- 支持文件后缀关联
- 支持非 png 格式的纹理图片格式
- 支持非 PNG 格式的纹理图片格式
- 支持开机自启常驻动态壁纸
- ......
### Spine 版本支持
| 版本 | 查看&导出 |
| :---: | :---: |
| `2.1.x` | :white_check_mark: |
| `3.4.x` | :white_check_mark: |
| `3.5.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:
### 导出格式支持
| 导出格式 | 适用场景 |
| --- | --- |
| 单帧画面 | 支持生成高清模型画面图像, 可手动调节需要的一帧. |
| 帧序列 | 支持 PNG 格式帧序列, 可保留透明通道且无损压缩. |
| 动图/视频 | 可以生成预览动图或者常见格式视频. |
| 自定义导出 | 除上述预设方案, 支持提供任意 FFmpeg 参数进行导出, 满足自定义复杂需求. |
## 安装
前往 [Release](https://github.com/ww-rm/SpineViewer/releases) 界面下载压缩包.
@@ -73,20 +39,26 @@ https://github.com/user-attachments/assets/37b6b730-088a-4352-827a-c338127a16f0
导出 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).
## 修改显示语言
本项目目前支持以下界面显示语言:
- `ZH` (中文)
- `EN` (English)
- `JA` (日本語)
可以通过窗口菜单的 "文件" -> "首选项..." -> "语言", 选择你需要的语言并确认修改.
## 使用方法
### 如何修改显示语言
窗口菜单的 "文件" -> "首选项..." -> "语言", 选择你需要的语言并确认修改.
### 基本介绍
程序大致是左右布局, 左侧是功能面板, 右侧是画面.
左侧有三个子面板, 分别是:
- **浏览**. 该面板用于预览指定文件夹的内容, 并没有真正导入文件到程序. 在该面板可以为模型生成 webp 格式的预览图, 或者导入选中的模型.
- **模型**. 该面板记录导入并进行渲染的模型列表, 可以在这个面板设置与模型渲染相关的参数和渲染顺序, 以及一些与模型有关的功能.
- **浏览**. 该面板用于预览指定文件夹的内容, 并没有真正导入文件到程序. 在该面板可以为模型生成 webp 格式的预览图, 或者导入选中的模型.
- **画面**. 该面板用于设置右侧预览画面的参数.
绝大部分按钮或者标签或者输入框都可以通过鼠标指针悬停来获取帮助文本.
@@ -105,16 +77,14 @@ https://github.com/user-attachments/assets/37b6b730-088a-4352-827a-c338127a16f0
- 左键可以选择和拖拽模型, 按下 `Ctrl` 键可以实现多选, 与左侧列表选择是联动的.
- 右键对整体画面进行拖动.
- 滚轮进行画面缩放, 按住 `Ctrl` 可以对选中的模型进行批量缩放.
- 滚轮进行画面缩放, 按住 `Ctrl` 可以对选中的模型进行批量缩放, `Shift` 可以切换缩放倍数.
- 仅渲染选中模式, 在该模式下, 预览画面仅包含被选中的模型, 并且只能通过左侧列表改变选中状态.
预览画面下方按钮支持对画面时间进行调整, 可以当作一个简易的播放器.
### 内容导出
导出遵循 "所见即所得" 原则, 即实时预览的画面就是你导出的画面.
在模型面板里, 右键菜单可以对选中项进行导出操作.
在模型列表里, 右键单击选中的模型, 弹出菜单里可以对选中项执行导出操作.
导出有以下几个关键参数:
@@ -130,6 +100,29 @@ https://github.com/user-attachments/assets/37b6b730-088a-4352-827a-c338127a16f0
如果希望开机自启常驻壁纸, 也可以在首选项中启用开机自启, 并且设置启动后需要加载的工作区文件.
### 命令行工具
项目附带一个纯命令行工具 `SpineViewerCLI`, 目前支持对单个模型执行一些简单操作, 例如参数值查询以及导出等, 并且 Release 界面提供 Windows 和 Linux 多平台二进制文件.
```bash
$ SpineViewerCLI -h
Description:
Root Command
Usage:
SpineViewerCLI [command] [options]
Options:
-q, --quiet Suppress console logging (quiet mode).
-?, -h, --help Show help and usage information
--version Show version information
Commands:
query <skel> Query information of single model
preview <skel> Preview a model
export <skel> Export single model
```
### 更多
更为详细的使用方法和说明见 [Wiki](https://github.com/ww-rm/SpineViewer/wiki), 有使用上的问题或者 BUG 可以提个 [Issue](https://github.com/ww-rm/SpineViewer/issues).
@@ -142,9 +135,10 @@ https://github.com/user-attachments/assets/37b6b730-088a-4352-827a-c338127a16f0
- [HandyControl](https://github.com/HandyOrg/HandyControl)
- [NLog](https://github.com/NLog/NLog)
- [SkiaSharp](https://github.com/mono/SkiaSharp)
- [Spectre.Console](https://github.com/spectreconsole/spectre.console)
---
*如果你觉得这个项目不错请给个 :star:, 并分享给更多人知道! :)*
*如果你觉得这个项目不错请给个 :star:, 并分享给更多人知道! :\)*
[![Stargazers over time](https://starchart.cc/ww-rm/SpineViewer.svg?variant=adaptive)](https://starchart.cc/ww-rm/SpineViewer)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,9 +23,11 @@ namespace SpineViewer
public partial class App : Application
{
#if DEBUG
public const bool IsDebug = true;
public const string AppName = "SpineViewer_D";
public const string ProgId = "SpineViewer_D.skel";
#else
public const bool IsDebug = false;
public const string AppName = "SpineViewer";
public const string ProgId = "SpineViewer.skel";
#endif
@@ -84,12 +86,14 @@ namespace SpineViewer
var fileTarget = new NLog.Targets.FileTarget("fileTarget")
{
Encoding = System.Text.Encoding.UTF8,
Layout = "${date:format=yyyy-MM-dd HH\\:mm\\:ss} - ${level:uppercase=true} - ${processid} - ${callsite-filename:includeSourcePath=false}:${callsite-linenumber} - ${message}",
AutoFlush = true,
CreateDirs = true,
FileName = "${basedir}/logs/app.log",
ArchiveFileName = "${basedir}/logs/app.{#}.log",
ArchiveNumbering = NLog.Targets.ArchiveNumberingMode.Rolling,
ArchiveAboveSize = 1048576,
MaxArchiveFiles = 5,
Layout = "${date:format=yyyy-MM-dd HH\\:mm\\:ss} - ${level:uppercase=true} - ${processid} - ${callsite-filename:includeSourcePath=false}:${callsite-linenumber} - ${message}",
ConcurrentWrites = true,
KeepFileOpen = false,
};

View File

@@ -1,19 +1,41 @@
using SkiaSharp;
using SFML.Graphics;
using SFML.System;
using SkiaSharp;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Media;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Runtime.InteropServices;
namespace SpineViewer.Extensions
{
public static class WpfExtension
{
public static FloatRect ToFloatRect(this Rect self)
{
return new((float)self.X, (float)self.Y, (float)self.Width, (float)self.Height);
}
public static Vector2f ToVector2f(this Size self)
{
return new((float)self.Width, (float)self.Height);
}
public static Vector2u ToVector2u(this Size self)
{
return new((uint)self.Width, (uint)self.Height);
}
public static Vector2i ToVector2i(this Size self)
{
return new((int)self.Width, (int)self.Height);
}
/// <summary>
/// 从本地 WebP 文件读取,并保留透明度,返回一个可以直接用于 WPF Image.Source 的 BitmapSource。
/// </summary>

View File

@@ -33,6 +33,12 @@ namespace SpineViewer.Models
#endregion
#region
public string? ExploringDirectory { get; set; }
#endregion
#region
public uint ResolutionX { get; set; } = 1500;

View File

@@ -12,7 +12,6 @@ namespace SpineViewer.Models
{
public class WorkspaceModel
{
public string? ExploringDirectory { get; set; }
public RendererWorkspaceConfigModel RendererConfig { get; set; } = new();
public List<SpineObjectWorkspaceConfigModel> LoadedSpineObjects { get; set; } = [];
}

View File

@@ -206,12 +206,12 @@
<s:String x:Key="Str_QualityParameterTooltip" xml:space="preserve">[Webp]&#x0A;Quality parameter, range 0-100, higher value means better quality</s:String>
<s:String x:Key="Str_LosslessParam">Lossless Compression</s:String>
<s:String x:Key="Str_LosslessParamTooltip" xml:space="preserve">[Webp]&#x0A;Lossless compression, quality parameter will be ignored</s:String>
<s:String x:Key="Str_ApngPred">Predictor Method</s:String>
<s:String x:Key="Str_ApngPredTooltip" xml:space="preserve">[Apng]&#x0A;Pred parameter, value range 0-5, corresponding to different encoding strategies: none, sub, up, avg, paeth, and mixed.&#x0A;It affects encoding time and file size.</s:String>
<s:String x:Key="Str_PredMethod">Predictor Method</s:String>
<s:String x:Key="Str_PredMethodTooltip" xml:space="preserve">[Apng]&#x0A;Pred parameter, value range 0-5, corresponding to different encoding strategies: none, sub, up, avg, paeth, and mixed.&#x0A;It affects encoding time and file size.</s:String>
<s:String x:Key="Str_CrfParameter">CRF Parameter</s:String>
<s:String x:Key="Str_CrfParameterTooltip" xml:space="preserve">[Mp4/Webm/Mkv]&#x0A;CRF parameter, range 0-63, lower value means higher quality</s:String>
<s:String x:Key="Str_ProfileParameter">Profile Parameter</s:String>
<s:String x:Key="Str_ProfileParameterTooltip" xml:space="preserve">[Mov]&#x0A;Profile parameter, integer between -1 and 5,&#x0A;-1 means automatic, higher values indicate higher quality,&#x0A;Alpha channel encoding is only available when value is 4 or higher</s:String>
<s:String x:Key="Str_ProfileParameterTooltip" xml:space="preserve">[Mov]&#x0A;Profile parameter, an integer between -1 and 5,&#x0A;corresponding to: auto, proxy, lt, standard, hq, 4444, and 4444xq.&#x0A;Alpha channel encoding is available only when the value is 4 or higher.</s:String>
<s:String x:Key="Str_FFmpegFormat">Export Format</s:String>
<s:String x:Key="Str_FFmpegFormatTooltip">FFmpeg export format (equivalent to "-f"), e.g. "mp4", "webm"</s:String>

View File

@@ -206,12 +206,12 @@
<s:String x:Key="Str_QualityParameterTooltip" xml:space="preserve">[Webp]&#x0A;品質パラメータ、範囲は0-100。値が高いほど品質が良い</s:String>
<s:String x:Key="Str_LosslessParam">無損失圧縮</s:String>
<s:String x:Key="Str_LosslessParamTooltip" xml:space="preserve">[Webp]&#x0A;無損失圧縮、品質パラメータは無視されます</s:String>
<s:String x:Key="Str_ApngPred">予測器方式</s:String>
<s:String x:Key="Str_ApngPredTooltip" xml:space="preserve">[Apng]&#x0A;Pred パラメータ。値の範囲は 05 で、それぞれ none、sub、up、avg、paeth、mixed の異なるエンコード方式に対応します。&#x0A;エンコード時間とファイルサイズに影響します。</s:String>
<s:String x:Key="Str_PredMethod">予測器方式</s:String>
<s:String x:Key="Str_PredMethodTooltip" xml:space="preserve">[Apng]&#x0A;Pred パラメータ。値の範囲は 05 で、それぞれ none、sub、up、avg、paeth、mixed の異なるエンコード方式に対応します。&#x0A;エンコード時間とファイルサイズに影響します。</s:String>
<s:String x:Key="Str_CrfParameter">CRF パラメータ</s:String>
<s:String x:Key="Str_CrfParameterTooltip" xml:space="preserve">[Mp4/Webm/Mkv]&#x0A;CRF パラメータ、範囲0-63。値が小さいほど品質が高い</s:String>
<s:String x:Key="Str_ProfileParameter">プロファイルパラメータ</s:String>
<s:String x:Key="Str_ProfileParameterTooltip" xml:space="preserve">[Mov]&#x0A;プロファイルパラメータ、-1から5の整数、&#x0A;-1は自動、値が大きいほど品質が高い、&#x0A;値が4以上の場合のみアルファチャンネルエンコード可能</s:String>
<s:String x:Key="Str_ProfileParameterTooltip" xml:space="preserve">[Mov]&#x0A;Profile パラメータ。値は -1 5 の整数、&#x0A;それぞれ auto、proxy、lt、standard、hq、4444、4444xq に対応します。&#x0A;値が 4 以上の場合のみアルファチャンネルエンコード可能です。</s:String>
<s:String x:Key="Str_FFmpegFormat">エクスポートフォーマット</s:String>
<s:String x:Key="Str_FFmpegFormatTooltip">FFmpegエクスポートフォーマット。パラメーター“-f”に相当します。例: “mp4”、“webm”</s:String>

View File

@@ -206,12 +206,12 @@
<s:String x:Key="Str_QualityParameterTooltip" xml:space="preserve">[Webp]&#x0A;质量参数,取值范围 0-100越高质量越好</s:String>
<s:String x:Key="Str_LosslessParam">无损压缩</s:String>
<s:String x:Key="Str_LosslessParamTooltip" xml:space="preserve">[Webp]&#x0A;无损压缩,会忽略质量参数</s:String>
<s:String x:Key="Str_ApngPred">预测器方法</s:String>
<s:String x:Key="Str_ApngPredTooltip" xml:space="preserve">[Apng]&#x0A;Pred 参数,取值范围 0-5分别对应 none、sub、up、avg、paeth、mixed 几种不同的编码策略,&#x0A;影响编码时间和文件大小</s:String>
<s:String x:Key="Str_PredMethod">预测器方法</s:String>
<s:String x:Key="Str_PredMethodTooltip" xml:space="preserve">[Apng]&#x0A;Pred 参数,取值范围 0-5分别对应 none、sub、up、avg、paeth、mixed 几种不同的编码策略,&#x0A;影响编码时间和文件大小</s:String>
<s:String x:Key="Str_CrfParameter">CRF 参数</s:String>
<s:String x:Key="Str_CrfParameterTooltip" xml:space="preserve">[Mp4/Webm/Mkv]&#x0A;CRF 参数,取值范围 0-63越小质量越高</s:String>
<s:String x:Key="Str_ProfileParameter">Profile 参数</s:String>
<s:String x:Key="Str_ProfileParameterTooltip" xml:space="preserve">[Mov]&#x0A;Profile 参数,取值集合为 -1 到 5 之间的整数,&#x0A;-1 表示自动0-5 取值越高质量越高&#x0A;仅在取值大于等于 4 时可以编码透明度通道</s:String>
<s:String x:Key="Str_ProfileParameterTooltip" xml:space="preserve">[Mov]&#x0A;Profile 参数,取值范围为 -1 到 5 之间的整数,&#x0A;分别对应 auto、proxy、lt、standard、hq、4444、4444xq 几种配置&#x0A;仅在取值大于等于 4 时可以编码透明度通道</s:String>
<s:String x:Key="Str_FFmpegFormat">导出格式</s:String>
<s:String x:Key="Str_FFmpegFormatTooltip">FFmpeg 导出格式,等价于参数 “-f”例如 “mp4”、“webm”</s:String>

View File

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

View File

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

View File

@@ -20,13 +20,17 @@ namespace SpineViewer.ViewModels.Exporters
{
public class FrameExporterViewModel(MainWindowViewModel vmMain) : BaseExporterViewModel(vmMain)
{
public static ImmutableArray<SKEncodedImageFormat> FrameFormatOptions { get; } = Enum.GetValues<SKEncodedImageFormat>().ToImmutableArray();
public static ImmutableArray<SKEncodedImageFormat> FrameFormatOptions { get; } = [
SKEncodedImageFormat.Png,
SKEncodedImageFormat.Webp,
SKEncodedImageFormat.Jpeg,
];
public SKEncodedImageFormat Format { get => _format; set => SetProperty(ref _format, value); }
protected SKEncodedImageFormat _format = SKEncodedImageFormat.Png;
public int Quality { get => _quality; set => SetProperty(ref _quality, Math.Clamp(value, 0, 100)); }
protected int _quality = 80;
protected int _quality = 100;
private string FormatSuffix
{

View File

@@ -27,7 +27,24 @@ namespace SpineViewer.ViewModels.MainWindow
_preferenceViewModel = new(this);
}
public string Title => $"SpineViewer - v{App.Version}";
public bool IsDebug => App.IsDebug;
public string Title => $"{App.AppName} - v{App.Version}";
public Visibility Visibility
{
get => _visibility;
set
{
if (SetProperty(ref _visibility, value))
{
OnPropertyChanged(nameof(IsVisible));
}
}
}
private Visibility _visibility = Visibility.Visible;
public bool IsVisible => _visibility == Visibility.Visible;
/// <summary>
/// 指示是否通过托盘图标进行退出
@@ -164,14 +181,12 @@ namespace SpineViewer.ViewModels.MainWindow
{
return new()
{
ExploringDirectory = _explorerListViewModel.CurrentDirectory,
RendererConfig = _sfmlRendererViewModel.WorkspaceConfig,
LoadedSpineObjects = _spineObjectListViewModel.LoadedSpineObjects
};
}
set
{
_explorerListViewModel.CurrentDirectory = value.ExploringDirectory;
_sfmlRendererViewModel.WorkspaceConfig = value.RendererConfig;
_spineObjectListViewModel.LoadedSpineObjects = value.LoadedSpineObjects;
}

View File

@@ -153,7 +153,7 @@ namespace SpineViewer.ViewModels.MainWindow
public uint MaxFps
{
get => _renderer.MaxFps;
set => SetProperty(_renderer.MaxFps, value, v => _renderer.MaxFps = value);
set => SetProperty(_renderer.MaxFps, value, v => _renderer.MaxFps = _wallpaperRenderer.MaxFps = value);
}
public float Speed
@@ -320,7 +320,8 @@ namespace SpineViewer.ViewModels.MainWindow
public void CanvasMouseWheelScrolled(object? s, SFML.Window.MouseWheelScrollEventArgs e)
{
var factor = e.Delta > 0 ? 1.1f : 0.9f;
float delta = ((Keyboard.Modifiers & ModifierKeys.Shift) == 0) ? 0.1f : 0.01f;
var factor = e.Delta > 0 ? (1f + delta) : (1f - delta);
if ((Keyboard.Modifiers & ModifierKeys.Control) == 0)
{
Zoom = Math.Clamp(Zoom * factor, 0.001f, 1000f); // 滚轮缩放限制一下缩放范围
@@ -484,21 +485,17 @@ namespace SpineViewer.ViewModels.MainWindow
_forwardDelta = 0;
}
using var v = _renderer.GetView();
_renderer.Clear(_backgroundColor);
using var view = _renderer.GetView();
_wallpaperRenderer.SetView(view);
if (_wallpaperView)
{
_wallpaperRenderer.SetView(v);
_wallpaperRenderer.Clear(_backgroundColor);
}
if (_vmMain.IsVisible) _renderer.Clear(_backgroundColor);
if (_wallpaperView) _wallpaperRenderer.Clear(_backgroundColor);
// 渲染背景
lock (_bgLock)
{
if (_backgroundImageSprite is not null)
{
using var view = _renderer.GetView();
var bg = _backgroundImageSprite;
var viewSize = view.Size;
var bgSize = bg.Texture.Size;
@@ -521,16 +518,13 @@ namespace SpineViewer.ViewModels.MainWindow
bg.Scale = new(signX * scaleX, signY * scaleY);
bg.Position = view.Center;
bg.Rotation = view.Rotation;
_renderer.Draw(bg);
if (_wallpaperView)
{
_wallpaperRenderer.Draw(bg);
}
if (_vmMain.IsVisible) _renderer.Draw(bg);
if (_wallpaperView) _wallpaperRenderer.Draw(bg);
}
}
if (_showAxis)
if (_showAxis && _vmMain.IsVisible)
{
// 画一个很长的坐标轴, 用 1e9 比较合适
_axisVertices[0] = new(new(-1e9f, 0), _axisColor);
@@ -551,35 +545,30 @@ namespace SpineViewer.ViewModels.MainWindow
sp.Update(0); // 避免物理效果出现问题
sp.Update(delta * _speed);
// 为选中对象绘制一个半透明背景
if (sp.IsSelected)
if (_vmMain.IsVisible)
{
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);
}
// 为选中对象绘制一个半透明背景
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;
if (_wallpaperView)
{
_wallpaperRenderer.Draw(sp);
// 仅在预览画面临时启用调试模式
sp.EnableDebug = true;
_renderer.Draw(sp);
sp.EnableDebug = false;
}
if (_wallpaperView) _wallpaperRenderer.Draw(sp);
}
}
_renderer.Display();
if (_wallpaperView)
{
_wallpaperRenderer.Display();
}
if (_vmMain.IsVisible) _renderer.Display();
if (_wallpaperView) _wallpaperRenderer.Display();
}
}
catch (Exception ex)

View File

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

View File

@@ -15,6 +15,7 @@
Height="720"
Background="{DynamicResource RegionBrush}"
WindowStartupLocation="CenterScreen"
Visibility="{Binding Visibility, Mode=OneWayToSource}"
PreviewKeyDown="MainWindow_PreviewKeyDown"
LocationChanged="MainWindow_LocationChanged"
SizeChanged="MainWindow_SizeChanged">
@@ -31,6 +32,7 @@
MouseDoubleClick="_notifyIcon_MouseDoubleClick">
<hc:NotifyIcon.ContextMenu>
<ContextMenu>
<MenuItem Header="{DynamicResource Str_Debug}" Click="DebugMenuItem_Click" Visibility="{Binding IsDebug, Mode=OneWay, Converter={StaticResource Boolean2VisibilityConverter}}"/>
<MenuItem Header="{DynamicResource Str_WallpaperView}" Command="{Binding Cmd_SwitchWallpaperView}" IsChecked="{Binding PreferenceViewModel.WallpaperView}"/>
<Separator/>
<MenuItem Header="{DynamicResource Str_Exit}" Command="{Binding Cmd_ExitFromTray}"/>
@@ -63,7 +65,7 @@
<MenuItem Header="{DynamicResource Str_Diagnostics}" Command="{Binding Cmd_ShowDiagnosticsDialog}"/>
<Separator/>
<MenuItem Header="{DynamicResource Str_Abount}" Command="{Binding Cmd_ShowAboutDialog}"/>
<MenuItem Header="{DynamicResource Str_Debug}" Click="DebugMenuItem_Click"/>
<MenuItem Header="{DynamicResource Str_Debug}" Click="DebugMenuItem_Click" Visibility="{Binding IsDebug, Mode=OneWay, Converter={StaticResource Boolean2VisibilityConverter}}"/>
</MenuItem>
<!--<MenuItem Header="{DynamicResource Str_Experiment}"/>-->
</Menu>
@@ -682,125 +684,6 @@
</Grid>
</TabItem>
<!-- 浏览页 -->
<TabItem DataContext="{Binding ExplorerListViewModel}">
<TabItem.Header>
<Border Style="{StaticResource MyTabItemHeaderContainerStyle}"
MouseLeftButtonDown="MainTabControlHeader_MouseLeftButtonDown">
<Viewbox Width="24" Height="24" ToolTip="{DynamicResource Str_Explorer}">
<Path Data="{StaticResource Geo_Image}" Style="{StaticResource MyTabItemHeaderPathStyle}"/>
</Viewbox>
</Border>
</TabItem.Header>
<Grid x:Name="_explorerGrid">
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition Height="Auto"/>
<RowDefinition/>
</Grid.RowDefinitions>
<DockPanel>
<Grid DockPanel.Dock="Top">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<hc:TextBox hc:InfoElement.Placeholder="{DynamicResource Str_Filter}"
Text="{Binding FilterString, UpdateSourceTrigger=PropertyChanged}"/>
<Button Grid.Column="1"
hc:IconElement.Geometry="{StaticResource Geo_Folder}"
Command="{Binding Cmd_ChangeCurrentDirectory}"
ToolTip="{DynamicResource Str_ChangeCurrentDirectoryTooltip}"/>
<Button Grid.Column="2"
hc:IconElement.Geometry="{StaticResource Geo_ArrowRotateRight}"
Command="{Binding Cmd_RefreshItems}"
ToolTip="{DynamicResource Str_RefreshItemsTooltip}"/>
</Grid>
<StatusBar DockPanel.Dock="Bottom">
<TextBlock>
<TextBlock.Text>
<MultiBinding Converter="{StaticResource StrFmtCvter}" ConverterParameter="Str_ListViewStatusBar">
<Binding Path="Items.Count" ElementName="_spineFilesListBox"/>
<Binding Path="SelectedItems.Count" ElementName="_spineFilesListBox"/>
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StatusBar>
<ListBox x:Name="_spineFilesListBox"
VirtualizingPanel.IsVirtualizing="True"
ItemsSource="{Binding ShownItems}"
DisplayMemberPath="FileName"
MouseLeftButtonDown="SpineFilesListBox_MouseLeftButtonDown">
<i:Interaction.Triggers>
<i:EventTrigger EventName="SelectionChanged">
<i:InvokeCommandAction Command="{Binding Cmd_SelectionChanged}"
CommandParameter="{Binding SelectedItems, ElementName=_spineFilesListBox}"/>
</i:EventTrigger>
</i:Interaction.Triggers>
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="{DynamicResource Str_AddSelectedItems}"
Command="{Binding Cmd_AddSelectedItems}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<Separator/>
<MenuItem Header="{DynamicResource Str_GeneratePreviewForSelected}"
Command="{Binding Cmd_GeneratePreviews}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<MenuItem Header="{DynamicResource Str_DeletePreviewsForSelected}"
Command="{Binding Cmd_DeletePreviews}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
</ContextMenu>
</ListBox.ContextMenu>
</ListBox>
</DockPanel>
<GridSplitter Grid.Row="1" ResizeDirection="Rows"/>
<Grid Grid.Row="2" DataContext="{Binding SelectedItem}">
<Grid.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>
</Grid.Resources>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 文件目录 -->
<Label Grid.Row="0" Grid.Column="0" Content="{DynamicResource Str_FileDirectory}"/>
<TextBox Grid.Row="0" Grid.Column="1"
Text="{Binding FileDirectory, Mode=OneWay}"
IsReadOnly="True"
ToolTip="{Binding Text, RelativeSource={RelativeSource Mode=Self}}"/>
<!-- 文件名 -->
<Label Grid.Row="1" Grid.Column="0" Content="{DynamicResource Str_FileName}"/>
<TextBox Grid.Row="1" Grid.Column="1"
Text="{Binding FileName, Mode=OneWay}"
IsReadOnly="True"/>
<!-- 预览图 -->
<Border Grid.Row="2" Grid.ColumnSpan="2" Background="{DynamicResource DarkDefaultBrush}">
<Image Source="{Binding PreviewImage, Mode=OneWay}" Stretch="Uniform"/>
</Border>
</Grid>
</Grid>
</TabItem>
<!-- 画面参数页 -->
<TabItem DataContext="{Binding SFMLRendererViewModel}">
<TabItem.Header>
@@ -991,6 +874,126 @@
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- 浏览页 -->
<TabItem DataContext="{Binding ExplorerListViewModel}">
<TabItem.Header>
<Border Style="{StaticResource MyTabItemHeaderContainerStyle}"
MouseLeftButtonDown="MainTabControlHeader_MouseLeftButtonDown">
<Viewbox Width="24" Height="24" ToolTip="{DynamicResource Str_Explorer}">
<Path Data="{StaticResource Geo_Image}" Style="{StaticResource MyTabItemHeaderPathStyle}"/>
</Viewbox>
</Border>
</TabItem.Header>
<Grid x:Name="_explorerGrid">
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition Height="Auto"/>
<RowDefinition/>
</Grid.RowDefinitions>
<DockPanel>
<Grid DockPanel.Dock="Top">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<hc:TextBox hc:InfoElement.Placeholder="{DynamicResource Str_Filter}"
Text="{Binding FilterString, UpdateSourceTrigger=PropertyChanged}"/>
<Button Grid.Column="1"
hc:IconElement.Geometry="{StaticResource Geo_Folder}"
Command="{Binding Cmd_ChangeCurrentDirectory}"
ToolTip="{DynamicResource Str_ChangeCurrentDirectoryTooltip}"/>
<Button Grid.Column="2"
hc:IconElement.Geometry="{StaticResource Geo_ArrowRotateRight}"
Command="{Binding Cmd_RefreshItems}"
ToolTip="{DynamicResource Str_RefreshItemsTooltip}"/>
</Grid>
<StatusBar DockPanel.Dock="Bottom">
<TextBlock Foreground="{DynamicResource PrimaryTextBrush}">
<TextBlock.Text>
<MultiBinding Converter="{StaticResource StrFmtCvter}" ConverterParameter="Str_ListViewStatusBar">
<Binding Path="Items.Count" ElementName="_spineFilesListBox"/>
<Binding Path="SelectedItems.Count" ElementName="_spineFilesListBox"/>
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StatusBar>
<ListBox x:Name="_spineFilesListBox"
VirtualizingPanel.IsVirtualizing="True"
ItemsSource="{Binding ShownItems}"
DisplayMemberPath="FileName"
MouseLeftButtonDown="SpineFilesListBox_MouseLeftButtonDown">
<i:Interaction.Triggers>
<i:EventTrigger EventName="SelectionChanged">
<i:InvokeCommandAction Command="{Binding Cmd_SelectionChanged}"
CommandParameter="{Binding SelectedItems, ElementName=_spineFilesListBox}"/>
</i:EventTrigger>
</i:Interaction.Triggers>
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="{DynamicResource Str_AddSelectedItems}"
Command="{Binding Cmd_AddSelectedItems}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<Separator/>
<MenuItem Header="{DynamicResource Str_GeneratePreviewForSelected}"
Command="{Binding Cmd_GeneratePreviews}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
<MenuItem Header="{DynamicResource Str_DeletePreviewsForSelected}"
Command="{Binding Cmd_DeletePreviews}"
CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/>
</ContextMenu>
</ListBox.ContextMenu>
</ListBox>
</DockPanel>
<GridSplitter Grid.Row="1" ResizeDirection="Rows"/>
<Grid Grid.Row="2" DataContext="{Binding SelectedItem}">
<Grid.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>
</Grid.Resources>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 文件目录 -->
<Label Grid.Row="0" Grid.Column="0" Content="{DynamicResource Str_FileDirectory}"/>
<TextBox Grid.Row="0" Grid.Column="1"
Text="{Binding FileDirectory, Mode=OneWay}"
IsReadOnly="True"
ToolTip="{Binding Text, RelativeSource={RelativeSource Mode=Self}}"/>
<!-- 文件名 -->
<Label Grid.Row="1" Grid.Column="0" Content="{DynamicResource Str_FileName}"/>
<TextBox Grid.Row="1" Grid.Column="1"
Text="{Binding FileName, Mode=OneWay}"
IsReadOnly="True"/>
<!-- 预览图 -->
<Border Grid.Row="2" Grid.ColumnSpan="2" Background="{DynamicResource DarkDefaultBrush}">
<Image Source="{Binding PreviewImage, Mode=OneWay}" Stretch="Uniform"/>
</Border>
</Grid>
</Grid>
</TabItem>
</TabControl>
</Border>

View File

@@ -55,8 +55,19 @@ public partial class MainWindow : Window
set
{
var mainTabContentHost = (ContentPresenter)_mainTabControl.Template.FindName("PART_SelectedContentHost", _mainTabControl);
if (mainTabContentHost is null)
{
_mainTabControl.ApplyTemplate();
mainTabContentHost = (ContentPresenter)_mainTabControl.Template.FindName("PART_SelectedContentHost", _mainTabControl);
}
if (mainTabContentHost is null)
{
_logger.Warn("Failed to set property {0}", nameof(RootGridCol0Folded));
return;
}
if ((mainTabContentHost.Visibility != Visibility.Visible) == value)
return;
if (value)
{
// 寄存折叠前的宽度比例
@@ -120,11 +131,11 @@ public partial class MainWindow : Window
var rtbTarget = new NLog.Windows.Wpf.RichTextBoxTarget
{
Name = "rtbTarget",
Layout = "[${level:format=OneLetter}]${date:format=yyyy-MM-dd HH\\:mm\\:ss} - ${message}",
WindowName = _mainWindow.Name,
ControlName = _loggerRichTextBox.Name,
AutoScroll = true,
MaxLines = 3000,
Layout = "[${level:format=OneLetter}]${date:format=yyyy-MM-dd HH\\:mm\\:ss} - ${message}",
};
rtbTarget.WordColoringRules.Add(new("[D]", "Gray", "Empty"));
@@ -167,29 +178,9 @@ public partial class MainWindow : Window
// 加载首选项
_vm.PreferenceViewModel.LoadPreference();
// 还原上一次用户历史状态
// 还原上一次用户历史状态并开启监听器
LoadUserState();
// 添加用户状态监听器
_userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.WidthProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.LeftProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.TopProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.WindowStateProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_rootGrid.ColumnDefinitions[0], ColumnDefinition.WidthProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_rootGrid.ColumnDefinitions[2], ColumnDefinition.WidthProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_modelListGrid.RowDefinitions[0], RowDefinition.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_modelListGrid.RowDefinitions[2], RowDefinition.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_explorerGrid.RowDefinitions[0], RowDefinition.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_explorerGrid.RowDefinitions[2], RowDefinition.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_rightPanelGrid.RowDefinitions[0], RowDefinition.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_rightPanelGrid.RowDefinitions[2], RowDefinition.HeightProperty, DelayedSaveUserState));
_vm.SFMLRendererViewModel.PropertyChanged += SFMLRendererUserStateChanged;
AddUserStateListeners();
}
private void MainWindow_ContentRendered(object? sender, EventArgs e)
@@ -229,14 +220,10 @@ public partial class MainWindow : Window
}
}
// 保存当前用户状态
// 移除监听器并保存当前用户状态
RemoveUserStateListensers();
SaveUserState();
// 撤除所有状态监听器
_vm.SFMLRendererViewModel.PropertyChanged -= SFMLRendererUserStateChanged;
foreach (var w in _userStateWatchers) w.Dispose();
_userStateWatchers.Clear();
_vm.SFMLRendererViewModel.StopRender();
}
@@ -284,6 +271,8 @@ public partial class MainWindow : Window
_rightPanelGrid.RowDefinitions[0].Height = new(m.RightPanelGridRow0Height, GridUnitType.Star);
_rightPanelGrid.RowDefinitions[2].Height = new(m.RightPanelGridRow2Height, GridUnitType.Star);
_vm.ExplorerListViewModel.CurrentDirectory = m.ExploringDirectory;
_vm.SFMLRendererViewModel.SetResolution(m.ResolutionX, m.ResolutionY);
_vm.SFMLRendererViewModel.MaxFps = m.MaxFps;
_vm.SFMLRendererViewModel.Speed = m.Speed;
@@ -317,6 +306,8 @@ public partial class MainWindow : Window
RightPanelGridRow0Height = _rightPanelGrid.RowDefinitions[0].Height.Value,
RightPanelGridRow2Height = _rightPanelGrid.RowDefinitions[2].Height.Value,
ExploringDirectory = _vm.ExplorerListViewModel.CurrentDirectory,
ResolutionX = _vm.SFMLRendererViewModel.ResolutionX,
ResolutionY = _vm.SFMLRendererViewModel.ResolutionY,
MaxFps = _vm.SFMLRendererViewModel.MaxFps,
@@ -356,6 +347,53 @@ public partial class MainWindow : Window
_saveUserStateTimer.Start();
}
private void AddUserStateListeners()
{
// 添加用户状态监听器
_userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.WidthProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.LeftProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.TopProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.WindowStateProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_rootGrid.ColumnDefinitions[0], ColumnDefinition.WidthProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_rootGrid.ColumnDefinitions[2], ColumnDefinition.WidthProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_modelListGrid.RowDefinitions[0], RowDefinition.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_modelListGrid.RowDefinitions[2], RowDefinition.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_explorerGrid.RowDefinitions[0], RowDefinition.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_explorerGrid.RowDefinitions[2], RowDefinition.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_rightPanelGrid.RowDefinitions[0], RowDefinition.HeightProperty, DelayedSaveUserState));
_userStateWatchers.Add(PropertyWatcher.Watch(_rightPanelGrid.RowDefinitions[2], RowDefinition.HeightProperty, DelayedSaveUserState));
_vm.ExplorerListViewModel.PropertyChanged += ExplorerListUserStateChanged;
_vm.SFMLRendererViewModel.PropertyChanged += SFMLRendererUserStateChanged;
}
private void RemoveUserStateListensers()
{
// 撤除所有状态监听器
_vm.SFMLRendererViewModel.PropertyChanged -= SFMLRendererUserStateChanged;
_vm.ExplorerListViewModel.PropertyChanged -= ExplorerListUserStateChanged;
foreach (var w in _userStateWatchers) w.Dispose();
_userStateWatchers.Clear();
}
private void ExplorerListUserStateChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(ExplorerListViewModel.CurrentDirectory):
DelayedSaveUserState();
break;
default:
break;
}
}
private void SFMLRendererUserStateChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)

View File

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

View File

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

View File

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

105
SpineViewerCLI/Extension.cs Normal file
View File

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

View File

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

View File

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

3
SpineViewerCLI/README.md Normal file
View File

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

View File

@@ -1,352 +1,100 @@
using System.Globalization;
using SFML.Graphics;
using SFML.System;
using NLog;
using SkiaSharp;
using Spectre.Console;
using Spine;
using Spine.Exporters;
using SkiaSharp;
using System.CommandLine;
using System.Globalization;
using System.Runtime.InteropServices;
namespace SpineViewerCLI
{
public class CLI
public static class SpineViewerCLI
{
const string USAGE = @"
usage: SpineViewerCLI.exe [--skel PATH] [--atlas PATH] [--output PATH] [--animation STR] [--skin STR] [--hide-slot STR] [--pma] [--fps INT] [--loop] [--crf INT] [--time FLOAT] [--quality INT] [--width INT] [--height INT] [--centerx INT] [--centery INT] [--zoom FLOAT] [--speed FLOAT] [--color HEX] [--quiet]
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
options:
--skel PATH Path to the .skel file
--atlas PATH Path to the .atlas file, default searches in the skel file directory
--output PATH Output file path. Extension determines export type (.mp4, .webm for video; .png, .jpg for frame)
--animation STR Animation name
--skin STR Skin name to apply. Can be used multiple times to stack skins.
--hide-slot STR Slot name to hide. Can be used multiple times.
--pma Use premultiplied alpha, default false
--fps INT Frames per second (for video), default 24
--loop Whether to loop the animation (for video), default false
--crf INT Constant Rate Factor (for video), from 0 (lossless) to 51 (worst), default 23
--time FLOAT Time in seconds to export a single frame. Providing this argument forces frame export mode.
--quality INT Quality for lossy image formats (jpg, webp), from 0 to 100, default 80
--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 (for video), default 1.0
--color HEX Background color as a hex RGBA color, default 000000ff (opaque black)
--quiet Removes console progress log, default false
";
public static void Main(string[] args)
public static Option<bool> OptQuiet { get; } = new("--quiet", "-q")
{
string? skelPath = null;
string? atlasPath = null;
string? output = null;
string? animation = null;
var skins = new List<string>();
var hideSlots = new List<string>();
bool pma = false;
uint fps = 24;
bool loop = false;
int crf = 23;
float? time = null;
int quality = 80;
uint? width = null;
uint? height = null;
int? centerx = null;
int? centery = null;
float zoom = 1;
float speed = 1;
Color backgroundColor = Color.Black;
bool quiet = false;
Description = "Suppress console logging (quiet mode).",
Recursive = true,
};
for (int i = 0; i < args.Length; i++)
{
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 "--skin":
skins.Add(args[++i]);
break;
case "--hide-slot":
hideSlots.Add(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 "--time":
time = float.Parse(args[++i]);
break;
case "--quality":
quality = 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);
}
var outputExtension = Path.GetExtension(output).TrimStart('.').ToLowerInvariant();
var sp = new SpineObject(skelPath, atlasPath);
sp.UsePma = pma;
foreach (var skinName in skins)
{
if (!sp.SetSkinStatus(skinName, true))
{
var availableSkins = string.Join(", ", sp.Data.Skins.Select(s => s.Name));
Console.Error.WriteLine($"Error: Skin '{skinName}' not found. Available skins: {availableSkins}");
Environment.Exit(2);
}
}
if (string.IsNullOrEmpty(animation))
{
var availableAnimations = string.Join(", ", sp.Data.Animations.Select(a => a.Name));
Console.Error.WriteLine($"Missing --animation. Available animations for {sp.Name}: {availableAnimations}");
Environment.Exit(2);
}
var trackEntry = sp.AnimationState.SetAnimation(0, animation, loop);
if (time.HasValue)
{
trackEntry.TrackTime = time.Value;
}
sp.Update(0);
foreach (var slotName in hideSlots)
{
if (!sp.SetSlotVisible(slotName, false))
{
if (!quiet) Console.WriteLine($"Warning: Slot '{slotName}' not found, cannot hide.");
}
}
if (time.HasValue)
{
if (TryGetImageFormat(outputExtension, out var imageFormat))
{
if (!quiet) Console.WriteLine($"Exporting single frame at {time.Value:F2}s to {output}...");
FrameExporter exporter;
if (width is uint w && height is uint h && centerx is int cx && centery is int cy)
{
exporter = new FrameExporter(w, h)
{
Center = (cx, cy),
Size = (w / zoom, -h / zoom),
};
}
else
{
var frameBounds = GetSpineObjectBounds(sp);
var bounds = GetFloatRectCanvasBounds(frameBounds, new(width ?? 512, height ?? 512));
exporter = new FrameExporter(width ?? (uint)Math.Ceiling(bounds.Width), height ?? (uint)Math.Ceiling(bounds.Height))
{
Center = bounds.Position + bounds.Size / 2,
Size = (bounds.Width, -bounds.Height),
};
}
exporter.Format = imageFormat;
exporter.Quality = quality;
exporter.BackgroundColor = backgroundColor;
exporter.Export(output, sp);
if (!quiet)
Console.WriteLine("Frame export complete.");
}
else
{
var validImageExtensions = "png, jpg, jpeg, webp, bmp";
Console.Error.WriteLine($"Error: --time argument requires a valid image format extension. Supported formats are: {validImageExtensions}.");
Environment.Exit(2);
}
}
else if (Enum.TryParse<FFmpegVideoExporter.VideoFormat>(outputExtension, true, out var videoFormat))
{
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("\nVideo export complete.");
}
else
{
var validVideoExtensions = string.Join(", ", Enum.GetNames(typeof(FFmpegVideoExporter.VideoFormat)));
var validImageExtensions = "png, jpg, jpeg, webp, bmp";
Console.Error.WriteLine($"Invalid output extension or missing --time for image export. Supported video formats are: {validVideoExtensions}. Supported image formats (with --time) are: {validImageExtensions}.");
Environment.Exit(2);
}
Environment.Exit(0);
}
private static bool TryGetImageFormat(string extension, out SKEncodedImageFormat format)
public static int Main(string[] args)
{
switch (extension)
InitializeFileLog();
var cmdRoot = new RootCommand("Root Command")
{
case "png":
format = SKEncodedImageFormat.Png;
return true;
case "jpg":
case "jpeg":
format = SKEncodedImageFormat.Jpeg;
return true;
case "webp":
format = SKEncodedImageFormat.Webp;
return true;
case "bmp":
format = SKEncodedImageFormat.Bmp;
return true;
default:
format = default;
return false;
OptQuiet,
new QueryCommand(),
new PreviewCommand(),
new ExportCommand(),
};
var result = cmdRoot.Parse(args);
if (!result.GetValue(OptQuiet))
InitializeConsoleLog();
try
{
return result.Invoke();
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Fatal("Failed to execute, {0}", ex.Message);
return -1;
}
}
public static SpineObject CopySpineObject(SpineObject sp)
private static void InitializeFileLog()
{
var spineObject = new SpineObject(sp, true);
foreach (var tr in sp.AnimationState.IterTracks().Where(t => t is not null))
// XXX: δ֪ԭ<D6AA><D4AD> linux ƽ̨<C6BD><CCA8><EFBFBD>޷<EFBFBD><DEB7><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>־<EFBFBD>ļ<EFBFBD>
var config = new NLog.Config.LoggingConfiguration();
var fileTarget = new NLog.Targets.FileTarget("fileTarget")
{
var t = spineObject.AnimationState.SetAnimation(tr!.TrackIndex, tr.Animation, tr.Loop);
}
spineObject.Update(0);
return spineObject;
Encoding = System.Text.Encoding.UTF8,
Layout = "${date:format=yyyy-MM-dd HH\\:mm\\:ss} - ${level:uppercase=true} - ${processid} - ${callsite-filename:includeSourcePath=false}:${callsite-linenumber} - ${message}",
AutoFlush = true,
CreateDirs = true,
FileName = "${basedir}/logs/cli.log",
ArchiveFileName = "${basedir}/logs/cli.{#}.log",
ArchiveNumbering = NLog.Targets.ArchiveNumberingMode.Rolling,
ArchiveAboveSize = 1048576,
MaxArchiveFiles = 5,
ConcurrentWrites = true,
KeepFileOpen = false,
};
config.AddTarget(fileTarget);
config.AddRule(LogLevel.Trace, LogLevel.Fatal, fileTarget);
LogManager.Configuration = config;
}
static FloatRect GetSpineObjectBounds(SpineObject sp)
private static void InitializeConsoleLog()
{
sp.Skeleton.GetBounds(out var x, out var y, out var w, out var h);
return new(x, y, Math.Max(w, 1e-6f), Math.Max(h, 1e-6f));
}
static FloatRect FloatRectUnion(FloatRect a, FloatRect b)
{
float left = Math.Min(a.Left, b.Left);
float top = Math.Min(a.Top, b.Top);
float right = Math.Max(a.Left + a.Width, b.Left + b.Width);
float bottom = Math.Max(a.Top + a.Height, b.Top + b.Height);
return new FloatRect(left, top, right - left, bottom - top);
}
static FloatRect GetSpineObjectAnimationBounds(SpineObject sp, float fps = 10)
{
sp = CopySpineObject(sp);
var bounds = GetSpineObjectBounds(sp);
var maxDuration = sp.AnimationState.IterTracks().Select(t => t?.Animation.Duration ?? 0).DefaultIfEmpty(0).Max();
sp.Update(0);
for (float tick = 0, delta = 1 / fps; tick < maxDuration; tick += delta)
var config = new NLog.Config.LoggingConfiguration();
var consoleTarget = new NLog.Targets.ColoredConsoleTarget("consoleTarget")
{
bounds = FloatRectUnion(bounds, GetSpineObjectBounds(sp));
sp.Update(delta);
}
return bounds;
}
static FloatRect GetFloatRectCanvasBounds(FloatRect rect, Vector2u resolution)
{
float sizeW = rect.Width;
float sizeH = rect.Height;
float innerW = resolution.X;
float innerH = resolution.Y;
var scale = Math.Max(Math.Abs(sizeW / innerW), Math.Abs(sizeH / innerH));
var scaleW = scale * Math.Sign(sizeW);
var scaleH = scale * Math.Sign(sizeH);
Encoding = System.Text.Encoding.UTF8,
Layout = "[${level:format=OneLetter}]${date:format=yyyy-MM-dd HH\\:mm\\:ss} - ${message}",
AutoFlush = true,
DetectConsoleAvailable = true,
StdErr = true,
DetectOutputRedirected = true,
};
innerW *= scaleW;
innerH *= scaleH;
consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Trace", NLog.Targets.ConsoleOutputColor.DarkGray, NLog.Targets.ConsoleOutputColor.NoChange));
consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Debug", NLog.Targets.ConsoleOutputColor.DarkGray, NLog.Targets.ConsoleOutputColor.NoChange));
consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Info", NLog.Targets.ConsoleOutputColor.DarkGray, NLog.Targets.ConsoleOutputColor.NoChange));
consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Warn", NLog.Targets.ConsoleOutputColor.DarkYellow, NLog.Targets.ConsoleOutputColor.NoChange));
consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Error", NLog.Targets.ConsoleOutputColor.Red, NLog.Targets.ConsoleOutputColor.NoChange));
consoleTarget.RowHighlightingRules.Add(new("level == LogLevel.Fatal", NLog.Targets.ConsoleOutputColor.White, NLog.Targets.ConsoleOutputColor.DarkRed));
var x = rect.Left - (innerW - sizeW) / 2;
var y = rect.Top - (innerH - sizeH) / 2;
var w = resolution.X * scaleW;
var h = resolution.Y * scaleH;
return new(x, y, w, h);
config.AddTarget(consoleTarget);
config.AddRule(LogLevel.Trace, LogLevel.Fatal, consoleTarget);
LogManager.Configuration = config;
}
}
}
}

View File

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

67
SpineViewerCLI/Utils.cs Normal file
View File

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