导出数据转spine

This commit is contained in:
tianmo
2026-09-07 21:29:15 +08:00
parent d258d4f435
commit 3a7c0b4092
65 changed files with 6000 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
/build/
/.cache/
/tests/fixtures/external/
*.user
*.swp
.DS_Store
+130
View File
@@ -0,0 +1,130 @@
cmake_minimum_required(VERSION 3.25)
project(DateToSpine VERSION 0.1.0 LANGUAGES CXX)
option(DTS_BUILD_TESTS "Build DateToSpine tests" ON)
option(DTS_BUILD_GUI "Build the DateToSpine desktop application" ON)
option(DTS_BUILD_RUNTIME_PLUGINS "Build bundled Spine runtime adapters" ON)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_library(dts_core
src/domain/SpineVersion.cpp
src/atlas/AtlasParser.cpp
src/preview/RuntimePlugin.cpp
src/scan/SpineDataVersionDetector.cpp
src/editor/SpineEditorLocator.cpp
src/editor/SpineOutputParser.cpp
src/editor/SpineProbe.cpp
src/editor/SpineVersionAvailability.cpp
)
if(APPLE OR UNIX)
target_sources(dts_core PRIVATE src/platform/ProcessRunner_posix.cpp)
elseif(WIN32)
target_sources(dts_core PRIVATE src/platform/ProcessRunner_windows.cpp)
else()
message(FATAL_ERROR "Unsupported platform")
endif()
target_include_directories(dts_core PUBLIC src runtime-api)
target_link_libraries(dts_core PRIVATE ${CMAKE_DL_LIBS})
if(MSVC)
target_compile_options(dts_core PRIVATE /W4 /permissive- /utf-8)
else()
target_compile_options(dts_core PRIVATE -Wall -Wextra -Wpedantic)
endif()
if(DTS_BUILD_RUNTIME_PLUGINS)
include(cmake/SpineRuntime.cmake)
dts_add_runtime_adapter(38 "3.8" spine38 "spine-cpp/spine-cpp")
dts_add_runtime_adapter(40 "4.0" spine40 "spine-cpp/spine-cpp")
dts_add_runtime_adapter(41 "4.1" spine41 "spine-cpp/spine-cpp")
dts_add_runtime_adapter(42 "4.2" spine42 "spine-cpp/spine-cpp")
dts_add_runtime_adapter(43 "4.3" spine43 "spine-cpp")
endif()
add_executable(datetospine-cli src/app/cli_main.cpp)
target_link_libraries(datetospine-cli PRIVATE dts_core)
if(DTS_BUILD_GUI)
find_package(Qt6 REQUIRED COMPONENTS Concurrent OpenGLWidgets Widgets)
add_executable(DateToSpine MACOSX_BUNDLE WIN32
src/app/gui_main.cpp
src/conversion/ConversionJob.cpp
src/conversion/ConversionJob.hpp
src/ui/MainWindow.cpp
src/ui/MainWindow.hpp
src/ui/SpinePreviewWidget.cpp
src/ui/SpinePreviewWidget.hpp
)
set_target_properties(DateToSpine PROPERTIES
AUTOMOC ON
MACOSX_BUNDLE_GUI_IDENTIFIER io.datetospine.desktop
MACOSX_BUNDLE_BUNDLE_NAME DateToSpine)
target_link_libraries(DateToSpine PRIVATE
dts_core
Qt6::Concurrent
Qt6::OpenGLWidgets
Qt6::Widgets
)
if(DTS_BUILD_RUNTIME_PLUGINS)
add_dependencies(DateToSpine
dts_runtime_38 dts_runtime_40 dts_runtime_41 dts_runtime_42 dts_runtime_43)
foreach(runtime_line IN ITEMS 38 40 41 42 43)
add_custom_command(TARGET dts_runtime_${runtime_line} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory
"$<TARGET_BUNDLE_DIR:DateToSpine>/Contents/PlugIns/runtimes"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:dts_runtime_${runtime_line}>"
"$<TARGET_BUNDLE_DIR:DateToSpine>/Contents/PlugIns/runtimes/")
add_custom_command(TARGET DateToSpine POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory
"$<TARGET_BUNDLE_DIR:DateToSpine>/Contents/PlugIns/runtimes"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:dts_runtime_${runtime_line}>"
"$<TARGET_BUNDLE_DIR:DateToSpine>/Contents/PlugIns/runtimes/")
endforeach()
endif()
endif()
if(DTS_BUILD_TESTS)
enable_testing()
add_executable(dts_core_tests
tests/unit/CoreTests.cpp
)
target_link_libraries(dts_core_tests PRIVATE dts_core)
if(DTS_BUILD_RUNTIME_PLUGINS)
add_dependencies(dts_core_tests
dts_runtime_38 dts_runtime_40 dts_runtime_41 dts_runtime_42 dts_runtime_43)
target_compile_definitions(dts_core_tests PRIVATE
DTS_RUNTIME_38_PATH="$<TARGET_FILE:dts_runtime_38>"
DTS_RUNTIME_40_PATH="$<TARGET_FILE:dts_runtime_40>"
DTS_RUNTIME_41_PATH="$<TARGET_FILE:dts_runtime_41>"
DTS_RUNTIME_42_PATH="$<TARGET_FILE:dts_runtime_42>"
DTS_RUNTIME_43_PATH="$<TARGET_FILE:dts_runtime_43>"
DTS_TEST_38_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/3.8/examples/spineboy/export/spineboy-pro.json"
DTS_TEST_38_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/3.8/examples/spineboy/export/spineboy-pro.skel"
DTS_TEST_38_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/3.8/examples/spineboy/export/spineboy.atlas"
DTS_TEST_40_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.0/examples/spineboy/export/spineboy-pro.json"
DTS_TEST_40_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.0/examples/spineboy/export/spineboy-pro.skel"
DTS_TEST_40_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.0/examples/spineboy/export/spineboy.atlas"
DTS_TEST_41_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.1/examples/spineboy/export/spineboy-pro.json"
DTS_TEST_41_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.1/examples/spineboy/export/spineboy-pro.skel"
DTS_TEST_41_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.1/examples/spineboy/export/spineboy.atlas"
DTS_TEST_42_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.2/examples/spineboy/export/spineboy-pro.json"
DTS_TEST_42_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.2/examples/spineboy/export/spineboy-pro.skel"
DTS_TEST_42_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.2/examples/spineboy/export/spineboy.atlas"
DTS_TEST_SPINEBOY_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.3/examples/spineboy/export/spineboy-pro.json"
DTS_TEST_SPINEBOY_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.3/examples/spineboy/export/spineboy-pro.skel"
DTS_TEST_SPINEBOY_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.3/examples/spineboy/export/spineboy.atlas")
endif()
add_test(NAME dts_core_tests COMMAND dts_core_tests)
endif()
install(FILES third_party/spine-runtimes/4.3/spine-cpp/LICENSE
DESTINATION licenses
RENAME Spine-Runtimes-License.txt)
+65
View File
@@ -0,0 +1,65 @@
{
"version": 6,
"cmakeMinimumRequired": {
"major": 3,
"minor": 25,
"patch": 0
},
"configurePresets": [
{
"name": "macos-debug",
"displayName": "macOS Debug",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/macos-debug",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"DTS_BUILD_TESTS": "ON"
},
"condition": {
"type": "equals",
"lhs": "${hostSystemName}",
"rhs": "Darwin"
}
},
{
"name": "windows-msvc-debug",
"displayName": "Windows MSVC Debug",
"generator": "Visual Studio 17 2022",
"architecture": "x64",
"binaryDir": "${sourceDir}/build/windows-msvc-debug",
"cacheVariables": {
"DTS_BUILD_TESTS": "ON"
},
"condition": {
"type": "equals",
"lhs": "${hostSystemName}",
"rhs": "Windows"
}
}
],
"buildPresets": [
{
"name": "macos-debug",
"configurePreset": "macos-debug"
},
{
"name": "windows-msvc-debug",
"configurePreset": "windows-msvc-debug",
"configuration": "Debug"
}
],
"testPresets": [
{
"name": "macos-debug",
"configurePreset": "macos-debug",
"output": { "outputOnFailure": true }
},
{
"name": "windows-msvc-debug",
"configurePreset": "windows-msvc-debug",
"configuration": "Debug",
"output": { "outputOnFailure": true }
}
]
}
+37
View File
@@ -0,0 +1,37 @@
function(dts_add_spine_runtime target runtime_root)
if(NOT EXISTS "${runtime_root}/include/spine/spine.h")
message(FATAL_ERROR "Missing spine-cpp source at ${runtime_root}")
endif()
file(GLOB_RECURSE runtime_sources CONFIGURE_DEPENDS
"${runtime_root}/src/*.cpp")
file(GLOB_RECURSE runtime_headers CONFIGURE_DEPENDS
"${runtime_root}/include/*.h")
add_library(${target} STATIC ${runtime_sources} ${runtime_headers})
target_include_directories(${target} PUBLIC "${runtime_root}/include")
set_target_properties(${target} PROPERTIES POSITION_INDEPENDENT_CODE ON)
if(MSVC)
target_compile_options(${target} PRIVATE /W3 /utf-8)
else()
target_compile_options(${target} PRIVATE -Wno-deprecated-declarations)
endif()
endfunction()
function(dts_add_runtime_adapter suffix version_line source_directory runtime_subdirectory)
set(runtime_root
"${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/${version_line}/${runtime_subdirectory}")
set(runtime_target "dts_spine_cpp_${suffix}")
set(adapter_target "dts_runtime_${suffix}")
dts_add_spine_runtime(${runtime_target} "${runtime_root}")
add_library(${adapter_target} SHARED
"${CMAKE_CURRENT_SOURCE_DIR}/runtime-plugins/${source_directory}/RuntimeAdapter.cpp")
target_include_directories(${adapter_target} PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/runtime-api")
target_link_libraries(${adapter_target} PRIVATE ${runtime_target})
set_target_properties(${adapter_target} PROPERTIES
CXX_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN YES
OUTPUT_NAME "dts-runtime-${version_line}")
endfunction()
+146
View File
@@ -0,0 +1,146 @@
# 产品需求
## 1. 产品目标
DateToSpine 帮助用户批量处理仅保留运行时导出文件、但缺失原始 `.spine` 工程的资源。软件需要在转换前验证资源并显示可交互动画预览,用户确认后才调用本地 Spine Editor 生成可编辑工程。
产品目标是“尽可能恢复可编辑工程”,不是承诺字节级还原原工程。运行时导出时已经丢失的数据无法凭空恢复。
## 2. 用户前提
- 用户对待处理资源拥有合法使用和转换权限。
- 用户电脑已安装并激活 Spine Editor。
- 需要生成 `.spine` 时,本地已有与资源主/次版本相匹配的 Editor 版本。
- 未安装 Editor 时,软件仍可扫描、校验、配对和预览,但不能生成 `.spine`
## 3. 支持范围
### 3.1 骨骼数据
| 格式 | 支持状态 | 说明 |
|---|---|---|
| `.json` | 正式支持 | 必须通过 Spine schema 识别,不能把普通 JSON 当作骨骼数据 |
| `.skel` | 正式支持 | 按导出版本选择匹配 Runtime 和 Editor |
| `.skel.bytes` | 候选兼容 | 通过内容识别,扩展名支持在技术原型后决定 |
### 3.2 Spine 版本
| 版本 | 支持级别 |
|---|---|
| 3.8.20+ | 正式支持 |
| 3.8.0—3.8.19 | 尽力识别,不作正式保证 |
| 4.0.x | 正式支持 |
| 4.1.x | 正式支持 |
| 4.2.x | 正式支持 |
| 4.3.x | 正式支持 |
| 3.7.x 及更早 | 不支持 |
### 3.3 Atlas 与纹理页
- `.atlas``.atlas.txt`
- 单页和多页 Atlas
- PNG
- JPG/JPEG
- WebP
- 同一 Atlas 中不同扩展名的多页纹理
- Straight Alpha 和 Premultiplied Alpha
其他图片格式通过可扩展解码接口保留未来支持空间,但不属于首版承诺。
## 4. 主用户流程
主界面只暴露四个动作:
1. 用户把完整导出资源或资源文件夹拖入窗口。
2. 软件自动配对并显示预览;有多套资源时用顶部下拉框切换。
3. 用户用底部下拉框切换动画。
4. 用户点击“一键转换为 .spine”并选择保存位置。
版本检测、Atlas 配对、图片处理、Editor 验证和结果校验均在后台自动完成。只有自动找不到 Spine Editor 时,才要求用户指定 Spine 应用或可执行文件路径。普通流程不提供 Editor 管理页或技术参数面板。
## 5. 功能需求
### 5.1 扫描与配对
- 支持文件、文件夹和批量拖放。
- 支持递归扫描,允许配置最大深度。
- 根据 Atlas 中声明的页面名查找纹理,不能只依赖同名前缀。
- 根据附件 path、文件名、目录距离和名称前缀对数据与 Atlas 评分。
- 自动结果必须显示置信度。
- 歧义配对必须由用户确认,不能静默选择。
- 单个失败组不能终止整个批次。
### 5.2 预览
- 默认循环播放,支持动画切换。
- 画布自动适应窗口。
- 支持 Mesh、权重、Clipping、双颜色 Tint 和四种混合模式。
- 显示缺失纹理、版本错误和 Runtime 加载错误。
### 5.3 Editor 管理
- 自动查找常见安装位置。
- 自动查找失败时才显示路径选择器,支持自定义应用、可执行文件或安装目录。
- 支持一个安装入口对应多个本地可用版本。
- 转换前执行实际版本探测。
- 不读取、存储或传输 Spine 激活码。
### 5.4 批量转换
- 默认串行调用 Spine Editor,扫描和静态校验可并行。
- 支持停止等待中的任务和取消当前子进程。
- 支持跳过、自动重命名和覆盖策略。
- 默认不覆盖输入文件。
- 正式结果验证通过前只写入任务临时目录。
- 每组资源产生可读日志和机器可读报告。
## 6. 恢复能力边界
通常可恢复:骨骼、Slots、Skins、Attachments、约束、动画、Deform、事件和绘制顺序。
如果源文件导出时未启用 Nonessential data,可能永久缺失:
- 骨骼颜色和图标
- Mesh 手工边
- 编辑器辅助信息
- 部分附件原始尺寸
- 原工程图片和音频目录设置
- 编辑器视图与工作区状态
报告必须区分:
- 完整恢复
- 主体恢复但缺少 Nonessential data
- 工程生成成功但图片关联需要注意
- 转换失败
## 7. 非功能需求
- macOS 和 Windows 功能一致。
- 运行时完全离线,不依赖 CDN 或在线服务。
- 支持中文、空格、Unicode 和较长路径。
- 所有外部进程使用参数数组启动,不能拼接 shell 命令。
- 崩溃或取消不能留下半成品覆盖正式结果。
- 日志不能包含用户授权信息或不必要的绝对路径。
- 普通操作错误必须以用户可理解的语言呈现。
## 8. 首版不包含
- Spine 3.7 及更早版本。
- 绕过 Spine Editor 授权或下载 Editor。
- 自行生成或逆向写入 `.spine` 格式。
- 动画编辑功能。
- 云端转换、资源上传或账号系统。
- 将官方示例资产打包给最终用户。
## 9. 产品验收标准
- 五个目标版本均可完成 JSON、SKEL 的预览和转换测试。
- 断网环境可完成已准备版本的完整流程。
- PNG、JPG/JPEG、WebP 页面可预览。
- 纹理只允许使用匹配版本 Spine Editor 的官方 Texture Unpacker 解包。
- 生成物能被匹配版本 Editor 重新读取。
- 骨骼、Skin、附件、动画和事件数量通过语义核对。
- 原始输入不被修改。
- 单任务失败不影响批次其他任务。
- 最终安装包不含官方测试资源和 Spine Editor。
+217
View File
@@ -0,0 +1,217 @@
# 系统架构
## 1. 技术基线
| 项目 | 选择 |
|---|---|
| 语言 | C++20(第三方 Runtime target 可使用其原生标准) |
| UI | Qt 6 Widgets |
| 预览画布 | `QOpenGLWidget` + 自研批次渲染器 |
| 构建 | CMake 3.25+、CMake Presets |
| Windows 编译器 | Visual Studio 2022 / MSVC v143 |
| macOS 编译器 | Apple Clang |
| 进程管理 | `QProcess` |
| 设置存储 | `QSettings` |
| JSON | Qt JSON |
| 图片加载 | Qt Image plugins,随程序离线部署 |
| 测试 | Qt Test 或 Catch2,原型结束时二选一 |
CMake 只用于开发构建,不是最终用户的运行依赖。Windows 可以使用 Visual Studio Generator 或 Ninja + MSVC。
## 2. 分层结构
```text
UI
├─ 资源组表格
├─ 预览与播放控制
├─ Editor 管理
└─ 任务、日志与报告
Application Services
├─ ScanService
├─ PreviewService
├─ EditorRegistry
├─ RecoveryCoordinator
└─ ReportService
Domain
├─ AssetGroup
├─ SpineVersion
├─ EditorInstallation
├─ RecoveryJob
└─ RecoveryResult
Infrastructure
├─ Filesystem / Process
├─ Runtime Plugin Host
├─ Spine CLI Adapter
├─ Atlas Unpackers
└─ Output Publisher
```
UI 只表达状态和用户意图,不能直接调用 Spine CLI 或解析 Atlas。
## 3. 建议目录
```text
DateToSpine/
CMakeLists.txt
CMakePresets.json
cmake/
docs/
licenses/
resources/
icons/
shaders/
translations/
src/
app/
domain/
scan/
atlas/
editor/
preview/
recovery/
report/
platform/
macos/
windows/
ui/
util/
runtime-api/
runtime-plugins/
spine38/
spine40/
spine41/
spine42/
spine43/
third_party/
spine-runtimes/
licenses/
tests/
unit/
integration/
compatibility/
manifests/
fixtures/
generated/
external/
packaging/
macos/
windows/
```
`tests/fixtures/external/` 用于本地准备的官方测试资源,不进入发行包。
## 4. 多版本 Runtime 隔离
五套 Runtime 不能直接链接进同一个主程序命名空间。每个版本编译成独立动态模块,并隐藏所有上游 C++ 符号,只导出稳定的 DateToSpine C ABI。
```text
dts-runtime-3_8
dts-runtime-4_0
dts-runtime-4_1
dts-runtime-4_2
dts-runtime-4_3
```
公共 ABI 只允许:
- 固定宽度整数
- POD 结构体
- UTF-8 字节串
- 不透明句柄
- 显式长度的数组
- 函数指针回调
禁止跨模块传递 Qt 对象、STL 容器、异常或 Spine Runtime 类。
每个适配模块承担:
- 对应版本 JSON/SKEL 读取
- Atlas 绑定
- 动画状态推进
- Skin/Animation 查询和切换
- 把绘制结果转换为统一顶点、索引和 draw command
- 把上游错误转换为统一错误码
## 5. 预览渲染边界
Runtime 插件负责骨骼计算和裁剪;主程序渲染器负责:
- 纹理创建和缓存
- 顶点/索引上传
- Shader
- Blend mode
- 相机、背景和调试覆盖层
- DPI 和窗口生命周期
Runtime 不直接创建 Qt 或 OpenGL 对象。这使 Runtime 适配可独立测试,也避免上下文所有权问题。
## 6. Spine CLI 边界
所有 Editor 调用必须经过 `ISpineCli`
```text
probe installation
probe version
import data
export normalized json
unpack atlas
inspect project
```
实现要求:
- 使用 `QProcess::setProgram``setArguments`
- 不经过 shell。
- 每次调用有超时、取消令牌、工作目录和独立日志。
- 只终止本软件启动的子进程。
- 对 Windows 优先选择 `Spine.com`
- 对 macOS 把 `.app` 规范化为内部可执行文件。
## 7. 任务并发
- 扫描、哈希、图片头校验可以在线程池执行。
- OpenGL 资源操作只在所属渲染线程执行。
- Spine Editor 调用默认全局串行。
- Spine 官方 Atlas 解包与工程导入全局串行,并受任务取消状态约束。
- 每个恢复任务有独立临时目录和取消状态。
## 8. 文件长度和复杂度约束
自研代码执行以下软上限,超过时必须在评审中说明或拆分:
| 文件类型 | 建议上限 |
|---|---:|
| 头文件 | 200 行 |
| 普通 `.cpp` | 350 行 |
| UI `.cpp` | 450 行 |
| 单元测试文件 | 500 行 |
同时执行:
- 一个类只有一个主要职责。
- `MainWindow` 不包含解析、转换和进程逻辑。
- 禁止无边界的 `Utils` 类。
- 平台代码不得散落到业务模块。
- 版本差异留在 Runtime adapter 和 version policy 中。
- 第三方上游源码保持原样,位于 `third_party/`,不纳入自研文件行数限制。
CI 将检查自研源文件行数、循环依赖和禁止目录引用。
## 9. 可测试性
核心依赖使用小接口注入:
```text
IFileSystem
IProcessRunner
ISpineCli
IRuntimePlugin
IAtlasUnpacker
IClock
IReportSink
```
单元测试不启动真实 Editor;集成和兼容测试才调用本地 Spine Editor。
@@ -0,0 +1,193 @@
# 版本识别与 Spine Editor 检测
## 1. 目标
在用户确认转换前,软件必须回答:
1. 资源由哪个 Spine 主/次版本导出?
2. 本机是否有可以处理该版本的 Spine Editor
3. 配置路径是否指向可用的 Spine CLI?
4. 在断网状态下,该版本是否能实际启动和导入?
路径存在不等于 Editor 可用。当前实现先读取本机 Spine 更新缓存,只把已经存在的精确版本视为可转换;转换时再次执行同一门禁。
## 2. 资源版本识别
### 2.1 JSON
读取 `skeleton.spine` 字段,并同时验证最小 Spine schema
- 根对象
- `bones` 数组
- 可选 `slots``skins``animations`
- 版本字段格式
只解析识别所需的小范围数据。无 Spine schema 的业务 JSON 必须忽略。
### 2.2 SKEL
识别顺序:
1. 对二进制头执行有界读取,尝试提取 hash 和版本字符串。
2. 用候选版本 Runtime 插件执行只读探测。
3. 必要时用已配置 Editor 的 info 命令验证。
4. 多个版本都失败则要求用户选择版本,并标记为未确认。
解析必须限制字符串长度、数组大小和文件大小,损坏文件不能导致无限分配。
## 3. Editor 数据模型
```text
EditorInstallation
id
displayName
platform
userSelectedPath
canonicalExecutable
discoverySource
lastVerifiedAt
baseVersion
signatureStatus
capabilities[]
EditorCapability
versionLine // 3.8, 4.0, 4.1, 4.2, 4.3
launchSelector // 可选 -u 参数或独立 executable
status
lastProbeResult
```
同一个安装入口可以声明多个本地版本能力;多个安装入口也可以映射到同一版本。用户可为每个版本选择首选项。
## 4. 自动发现
### 4.1 macOS
优先位置:
```text
/Applications/Spine.app
~/Applications/Spine.app
```
补充来源:
- 系统应用索引
- 当前会话已知应用路径
- 历史用户配置
用户选择 `.app` 时规范化为:
```text
Spine.app/Contents/MacOS/Spine
```
不递归扫描整个磁盘。
### 4.2 Windows
发现来源:
- HKLM/HKCU 安装与卸载注册信息
- `Program Files``Program Files (x86)` 常见位置
- `PATH`
- 开始菜单快捷方式目标
- 历史用户配置
CLI 优先选择 `Spine.com`。如果用户选择 `Spine.exe`,查找同目录同名 `.com`;找不到时允许保存,但状态为“GUI 路径,CLI 待验证”。
## 5. 自定义路径
用户可选择:
- macOS `.app`
- 内部 Spine 可执行文件
- Windows `Spine.com``Spine.exe`
- 包含上述文件的目录
保存前执行路径规范化:
- 解析相对路径
- 清理多余分隔符
- 保留用户显示路径
- 保存 canonical path
- 记录文件标识和修改时间,用于发现安装被替换
路径移动或升级后,不自动删除旧配置,而是标记失效并允许重新定位。
## 6. 验证步骤
### 6.1 静态验证
- 文件存在且不是目录。
- 当前用户可执行。
- macOS bundle 结构或 Windows 同目录文件合理。
- 可选检查平台签名;旧版签名异常只警告,不直接否定合法安装。
### 6.2 基础进程验证
使用参数数组执行 `--version`,捕获:
- 退出码
- 标准输出和错误输出
- 启动耗时
- 报告版本
设置短超时,只终止该次探测进程。
### 6.3 版本能力验证
对用户配置的目标版本执行实际启动探测。若使用 `-u <version>` 选择器:
- 只允许已确认在本地缓存中存在的精确版本,例如资源线为 4.3 时选择本地最新的 `4.3.23`
- 不使用 `--force`
- 不使用 `latest``stable``beta``x.x.xx` 版本选择器。
- 不由 DateToSpine 发起下载。
- 缓存缺失时不启动 Spine Launcher,直接报告“本地未缓存目标版本”,从源头避免 Launcher 下载。
- 启动进程前再次检查精确缓存文件,避免检测后被移动导致意外联网。
当前 macOS 检查 `~/Library/Application Support/Spine/updates`Windows 检查 `%APPDATA%/Spine/updates``%LOCALAPPDATA%/Spine/updates`。后续若 Spine 改变缓存结构,需要同步更新并重新做断网回归。
### 6.4 导入冒烟测试
在用户点击“深度验证”或首次转换前,可在临时目录执行小型导入:
- 输入与版本匹配的最小测试数据。
- 输出到临时目录。
- 验证退出码和 `.spine` 可再次读取。
- 完成立即清理临时结果。
发行版不能使用未获再分发确认的官方测试资产作为内置冒烟样本;需要自有最小样本或仅对用户当前资源执行验证。
## 7. 状态与用户提示
```text
Available
PathMissing
NotExecutable
NotSpine
CliCompanionMissing
VersionMismatch
VersionNotPrepared
NotActivated
ProbeTimedOut
PermissionDenied
LaunchFailed
Unknown
```
每个状态需要:
- 用户可读说明
- 技术详情
- 可执行的修复建议
- 是否允许预览
- 是否允许转换
资源全部载入后,主界面顶部直接显示当前资源的结果,例如“可转换 · Spine 4.3.23”或“缺少 Spine 4.1”。自动检测失败时,转换按钮才变为“指定 Spine 路径”。
## 8. 离线要求
DateToSpine 本身不联网。完整转换成立的前提是对应 Editor 版本已经在本机准备好并能在断网时运行。
离线验收必须在网络被禁用的独立测试环境进行,不能只通过代码审查推断。
+186
View File
@@ -0,0 +1,186 @@
# 恢复流水线
## 1. 状态机
```text
Discovered
→ Grouped
→ PreflightPassed
→ PreviewReady
→ UserConfirmed
→ EditorVerified
→ AtlasUnpacked
→ ProjectImported
→ ProjectVerified
→ Published
```
任一步骤失败进入 `Failed(stage, code, details)`。取消进入 `Cancelled`。失败与取消不能发布半成品。
## 2. 资源组
一个 `AssetGroup` 包含:
```text
id
skeletonDataPath
skeletonFormat
detectedVersion
atlasPath
atlasPages[]
candidateScore
scale
alphaMode
warnings[]
```
Atlas 页面名是纹理配对的权威来源。文件前缀只作为寻找数据与 Atlas 关系的辅助信号。
## 3. 预检
转换前检查:
- 数据格式和版本
- 版本是否在支持范围
- Atlas 基础语法
- 所有页面是否存在
- 图片解码和尺寸
- Atlas scale/PMA
- 输出命名冲突
- 目标 Editor 可用性
- 临时目录和输出目录可写性
- 磁盘空间粗略估计
预检不修改输入。
## 4. 用户确认边界
预览阶段不得生成正式 `.spine`。允许的临时操作仅包括:
- Runtime 加载
- 图片解码
- 静态校验
- Editor 只读版本探测
只有用户点击“开始转换”后,才能调用导入和解包命令。
## 5. 临时工作区
```text
job-<uuid>/
source-links/
normalized/
unpacked-images/
intermediate/
logs/
publish/
```
- 优先使用复制或只读访问,不能修改源文件。
- 是否允许安全硬链接作为优化,在跨平台验证后决定。
- 每个任务独立,名称使用内部 UUID,避免用户文件名注入路径。
## 6. Atlas 解包
1. 只调用匹配版本 Spine Editor 的官方 Texture Unpacker。
2. 校验页面、region 数量、输出尺寸和文件存在性。
3. 官方失败或验证失败时终止任务并显示原因,不使用其他解包实现。
4. 原始 `.atlas` 和打包纹理页只作为只读输入,不复制到最终结果。
5. 工程导入必须读取 Atlas 的 `scale` 并使用 `-s <数值>`,使骨骼与官方解包图片采用相同打包比例。
详细规则见 `06-图集解包设计.md`
## 7. JSON 输入工程生成
1. 复制 JSON 到临时目录。
2. 保留所有未知字段。
3. 仅在必要时更新临时副本中的 `skeleton.images` 相对路径。
4. 使用匹配主/次版本的 Editor 导入。
5. 输出临时 `.spine`
6. 使用 Editor info 或再次导出执行语义验证。
禁止修改源 JSON。
## 8. SKEL 输入工程生成
SKEL 的图片路径自动关联是技术原型重点。候选官方流程:
1. 匹配版本 Editor 把 SKEL 导入临时 `.spine`
2. Editor 从临时工程导出规范化 JSON。
3. 在规范化 JSON 临时副本中设置 `skeleton.images`
4. 匹配版本 Editor 再导入为最终候选 `.spine`
5. 比较初次导入、规范化 JSON 和最终工程的语义计数。
此流程避免自行解析并序列化全部 SKEL,也避免修改未公开的 `.spine` 格式。
原型必须验证:
- 默认 JSON 导出是否保留全部运行时语义。
- 动画曲线、Deform、Linked Mesh、约束和事件是否无损。
- 图片相对路径是否被最终 `.spine` 保存。
- 3.8—4.3 是否行为一致。
若某版本失败,保留“工程 + images 目录 + 首次打开路径提示”作为降级方案,但正式自动化验收前必须由产品方确认是否接受。
## 9. Scale 与 Alpha
- Atlas 显式 `scale` 优先。
- 数据和 Atlas 都没有足够信息时,不猜测为确定值。
- 可提供 1、0.5、0.25 等候选并让用户预览比较。
- 用户可通过底部“去除预乘 Alpha”开关决定是否将解包图片转换为 Straight Alpha。
- 为保证各版本行为一致,官方解包使用临时 Atlas 副本并关闭其自动去预乘;开关开启后由本地像素处理统一完成转换。
- 临时 Atlas 不修改源文件,也不进入最终结果。
## 10. 结果验证
### 10.1 文件级
- `.spine` 存在且非空。
- 所有预期图片存在且可解码。
- 输出路径不逃逸目标目录。
- 临时文件未混入正式结果。
### 10.2 语义级
至少比较:
- 骨骼数和名称
- Slot 数和名称
- Skin 数和名称
- Attachment 类型与数量
- 约束类型与数量
- Animation 名称、数量和时长
- Event 名称和数量
- Deform timeline 存在性
Nonessential 缺失作为恢复等级,不作为运行时语义失败。
### 10.3 可读性
- 使用匹配 Editor 重新读取生成项目。
- 捕获所有 warning/error。
- 版本不匹配警告视为失败,除非用户启用实验模式。
## 11. 原子发布
验证通过后:
1. 在输出目录同一文件系统创建候选目录。
2. 应用重名策略。
3. 一次性重命名为正式结果。
4. 覆盖时先备份或移入可恢复位置。
5. 发布失败保留诊断,但不留下伪成功目录。
## 12. 输出结构
```text
Output/
hero/
hero.spine
images/
body.png
face/
eye.png
```
当前简化产品不输出原始 Atlas、打包纹理页、日志或恢复报告。发生错误时直接弹窗给出失败阶段和 Spine 输出摘要;成功时弹窗显示完整输出目录。
+156
View File
@@ -0,0 +1,156 @@
# 离线预览器
## 1. 目标
预览器在不调用 Spine Editor、不开启网络的情况下直接读取:
```text
.json/.skel + .atlas/.atlas.txt + texture pages
```
用户通过预览确认资源配对、版本、Scale、Alpha、动画和 Skin 后,才进入转换。
## 2. Runtime 版本矩阵
| 数据 | Runtime 插件 |
|---|---|
| 3.8.20+ | `dts-runtime-3_8` |
| 4.0.x | `dts-runtime-4_0` |
| 4.1.x | `dts-runtime-4_1` |
| 4.2.x | `dts-runtime-4_2` |
| 4.3.x | `dts-runtime-4_3` |
每个插件基于官方仓库对应分支的 `spine-cpp`。Runtime 与数据主/次版本必须一致。
## 3. 公共插件 ABI
公共 API 使用版本号和结构体大小实现向前兼容:
```text
dtsRuntimeGetApiVersion
dtsRuntimeCreate
dtsRuntimeDestroy
dtsRuntimeLoad
dtsRuntimeGetMetadata
dtsRuntimeGetAnimations
dtsRuntimeGetSkins
dtsRuntimeSetAnimation
dtsRuntimeSetSkin
dtsRuntimeSeek
dtsRuntimeUpdate
dtsRuntimeBuildFrame
dtsRuntimeGetLastError
```
设计规则:
- 调用方提供结构体 `size`
- 插件返回的内存由插件释放。
- 错误不能以 C++ 异常越过 ABI。
- 所有字符串为 UTF-8,并带长度。
- 插件的所有上游符号默认隐藏。
- 主程序校验插件文件和 ABI 版本后才加载。
## 4. 统一绘制数据
插件输出与 Runtime 版本无关的 draw list
```text
PreviewFrame
bounds
vertices[]
indices[]
commands[]
DrawCommand
textureHandle
firstIndex
indexCount
blendMode
premultipliedAlpha
```
顶点至少包含:
- position
- uv
- light color
- dark color
Clipping 优先在对应 Runtime/adapter 中完成,使主渲染器只处理最终三角形。
## 5. 纹理加载
主程序实现统一 Texture Provider
- 根据 Atlas 页面路径读取本地文件。
- 拒绝 HTTP/HTTPS 和 data URL。
- 规范化相对路径并阻止目录逃逸。
- 支持 PNG、JPG/JPEG、WebP。
- 保留源 Alpha 模式元数据。
- 缓存按 canonical path、修改时间和文件大小失效。
图片解码插件必须随安装包部署,不能在运行时下载。
## 6. 渲染功能
首版必须支持:
- Region 和 Mesh
- Weighted Mesh、Linked Mesh
- Clipping
- Normal、Additive、Multiply、Screen
- 单颜色和 Two Color Tint
- PMA 和 Straight Alpha
- 多页 Atlas
- 高 DPI
- 骨骼、Mesh、边界、裁剪调试层
Shader 作为本地资源随程序部署,并在构建期或启动期从本地加载。
## 7. 播放状态
每个预览会话保存:
```text
selectedAnimation
selectedSkin
loop
playbackSpeed
time
paused
camera
background
debugFlags
```
切换资源组时保留各组会话,避免用户往返查看时丢失位置。
Seek 需要确定性:先恢复 setup pose,再从动画起点应用到目标时间,不能依赖累积浮点步进得到任意帧。
## 8. 错误隔离
首版采用动态插件;技术原型评估损坏 SKEL 是否可能使上游 Runtime 崩溃。如果无法通过输入边界检查和测试降低风险,发布版改为每个版本一个 helper process
```text
UI ↔ local IPC ↔ preview-worker-4_2
```
是否进程隔离由原型的崩溃与模糊测试结果决定,不在未验证前锁死。
## 9. 性能目标
- 普通单骨骼资源首次预览在本地 SSD 上目标小于 1 秒。
- 预览默认 60 FPS;窗口不可见时暂停刷新。
- 大资源限制最大纹理尺寸、顶点数、附件数和单帧命令数。
- 纹理解码在线程池执行,OpenGL 创建在渲染线程执行。
- 切换动画不重复加载 Atlas 纹理。
## 10. 预览验收
- 五个版本官方示例的选定动画与官方参考视觉一致。
- 动画名、Skin 名和时长与 Runtime 数据一致。
- PMA/Straight Alpha 无明显黑边或白边。
- Mesh、Clipping、双颜色 Tint 和混合模式分别有测试样本。
- 缺失纹理不会导致崩溃,并能在画布上定位缺失项。
- 拔网后所有预览功能保持可用。
+67
View File
@@ -0,0 +1,67 @@
# Atlas 官方解包设计
## 1. 唯一实现
纹理恢复只调用用户本机、已授权且与数据版本匹配的 Spine Editor Texture Unpacker。项目不实现 Atlas 裁切、旋转、透明边恢复或图片重采样,也不存在自研兼容解包路径。
```text
本地匹配版本 Spine Texture Unpacker
输出校验
失败并向用户显示具体原因
```
官方解包失败、输出缺失或校验失败时,任务直接失败,不尝试以另一套图片算法生成看似成功的结果。
## 2. 官方调用
```text
Spine -u <本地精确缓存版本>
-i <Atlas 纹理页目录>
-o <临时 images 目录>
-c <Atlas 文件>
```
- 只允许精确的本地缓存版本,禁止 `latest`、通配版本和强制下载参数。
- 原始 Atlas 与纹理页保持只读。
- 输出先写入任务临时目录,验证成功后再发布。
- 记录退出码与进程输出;成功退出后仍检查是否实际生成图片。
- 最终结果不复制原始 `.atlas` 和打包纹理页。
## 3. PMA 处理
PMA 转换同样交给官方 Texture Unpacker
- 开启“去除预乘 Alpha”时,原样传入源 Atlas,由 Spine 根据 `pma: true` 执行官方转换。
- 关闭时,仅建立临时 Atlas 元数据副本并把 `pma: true` 改为 `pma: false`,使官方解包器保留打包像素。
- 不再由 Qt 解码、逐像素处理或重新保存解包图片,避免 JPEG/WebP 二次有损编码以及错误的重复去预乘。
## 4. Atlas Scale 与工程导入
Texture Unpacker 输出的是 Atlas 实际打包尺寸。例如页面声明 `scale: 0.5` 时,解出的图片也是原图的一半像素尺寸,这不是压缩错误。
生成工程时必须把同一 Atlas 传给官方导入参数:
```text
Spine -u <本地精确缓存版本>
-i <骨骼 JSON 或 SKEL>
-s <Atlas 的 scale 数值>
-o <目标 .spine>
--import
```
`-s <数值>` 让 Editor 按 Atlas 的打包比例缩放骨骼、附件和动画数据。使用数值是为了兼容 3.8—4.3;旧版 Editor 不接受 Atlas 路径作为 `-s` 的值。遗漏此参数会形成“附件位置沿用原始坐标、解包图片却是半尺寸”的工程,表现为局部图片缩小和脱节。
多页 Atlas 必须使用一致的 `scale`;未声明时按 `1.0`。若各页比例不同,任务会说明原因并停止,避免生成内部尺寸不一致的工程。
## 5. 验证与失败规则
每次解包后至少检查:
- 进程正常结束。
- `images/` 中存在可识别的图片输出。
- 最终 `.spine` 文件存在且非空。
- 3.8、4.0、4.1、4.2、4.3 均使用对应版本 Editor 做导入回归。
任何一步失败都弹窗显示所属阶段和 Spine 返回的原因,不切换到非官方实现。
+195
View File
@@ -0,0 +1,195 @@
# 测试策略
## 1. 测试目标
测试需要证明四件事:
1. 每个目标版本使用正确 Runtime 和 Editor。
2. 预览结果在语义和视觉上正确。
3. 生成 `.spine` 后运行时主体数据没有意外丢失。
4. 断网、损坏输入和批量失败情况下行为安全。
## 2. 测试层级
### 2.1 单元测试
不启动 Spine Editor
- 版本字符串解析
- Spine JSON schema 识别
- SKEL 头部有界探测
- Atlas parser
- 自动配对评分
- 路径规范化和逃逸防护
- 输出重名策略
- 恢复状态机
- 报告序列化
- Editor 命令参数生成
### 2.2 组件测试
- 每个 Runtime 插件独立加载相应版本资源。
- Texture Provider 解码 PNG、JPG、WebP。
- 统一 draw list 校验。
- 官方解包结果的尺寸、Alpha 与 golden image 对比。
- Editor Locator 使用模拟注册信息和目录结构。
### 2.3 Editor 集成测试
需要本地已激活的 Spine Editor
- `--version` 探测
- JSON 导入
- SKEL 导入
- Atlas 官方解包
- 临时工程导出规范化 JSON
- 最终 `.spine` 可重新读取
- 取消、超时和错误码映射
### 2.4 端到端测试
从目录扫描开始,经过预览、确认、解包、导入、验证和发布。检查输入目录完全未改变。
## 3. 版本矩阵
每个版本至少覆盖:
| 版本 | JSON | SKEL | 预览 | 官方解包 | 工程生成 | 语义回读 |
|---|---:|---:|---:|---:|---:|---:|
| 3.8.20+ | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 4.0.x | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 4.1.x | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 4.2.x | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 4.3.x | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
## 4. 功能样本矩阵
- Region
- Unweighted/Weighted/Linked Mesh
- IK、Transform、Path constraints
- Clipping
- Deform
- Draw order
- Events
- 多动画、多 Skin
- Two Color Tint
- Normal/Additive/Multiply/Screen
- 单页、多页 Atlas
- PMA、Straight Alpha
- PNG、JPG/JPEG、WebP
- Atlas scale
- Polygon packing
不要求一个样本覆盖所有功能,采用可追踪的样本能力清单。
## 5. 官方测试资源
开发阶段从官方 `EsotericSoftware/spine-runtimes` 仓库各版本分支取得对应样例:
```text
3.8
4.0
4.1
4.2
4.3
```
每份资源由 manifest 记录:
```text
case id
source repository
branch
commit hash
relative paths
data version
features
expected animations/skins/counts
license reference
sha256
```
规则:
- 不用 4.3 资源测试旧 Runtime。
- 测试基于固定 commit,不追随浮动分支。
- 官方资源只放在开发测试缓存或 CI 临时空间。
- `tests/fixtures/external/` 不进入发行打包输入。
- 发行产物检查中把官方资源文件名和哈希作为禁止项。
- 未经许可确认,不把官方资源提交到对外发布制品。
## 6. 自有与生成测试数据
除官方样例外,创建可自由分发的最小数据用于:
- 安装后冒烟测试
- 损坏输入测试
- Unicode 路径测试
- 极限尺寸测试
- 输出命名冲突测试
生成数据的来源、生成脚本和许可必须清晰。若生成 `.spine`/导出数据需要 Spine Editor,构建产物仍按许可审查。
## 7. 视觉回归
在固定 viewport、背景、时间点、Skin 和动画下截图:
- 与批准的 golden image 比较。
- 对 JPG/WebP 使用合理像素误差。
- 对 Alpha 边缘增加专门区域检测。
- 单独测试 PMA 黑边、Straight Alpha 白边问题。
- GPU 差异造成的小量误差采用阈值,不使用完全逐字节相等。
## 8. 语义回归
转换前后生成规范摘要并比较:
```text
bones
slots
skins
attachments by type
constraints by type
animations and duration
events
deform timelines
draw-order timelines
```
Nonessential 数据单独报告,不与运行时语义失败混合。
## 9. 异常与安全测试
- 截断 SKEL
- 超长字符串和伪造数组长度
- 深层/异常 JSON
- Atlas 路径逃逸
- 重复 region 和页面
- 缺失或损坏图片
- 巨大图片和解压炸弹式输入
- Editor 路径失效
- Editor 未激活
- 子进程卡住、崩溃、返回非零
- 用户在各阶段取消
- 输出目录无权限或空间不足
## 10. 离线测试
在 macOS 和 Windows 上分别执行:
1. 准备并验证对应 Editor 版本。
2. 禁用网络接口或在隔离环境阻断网络。
3. 启动 DateToSpine。
4. 完成扫描、预览、官方解包、生成和回读。
5. 记录任何网络连接尝试。
6. 确认不存在 CDN、在线字体、在线 Shader、更新或遥测依赖。
## 11. 发行物检查
对最终 `.app/.dmg/.exe/installer` 解包检查:
- 不含官方测试素材。
- 不含 Spine Editor。
- 包含 Runtime License 和 Third-Party Notices。
- 包含所需 Qt 图片插件,WebP 在断网环境可用。
- 不含开发日志、绝对源码路径、测试缓存或调试密钥。
@@ -0,0 +1,133 @@
# 离线、打包与许可
## 1. 离线定义
DateToSpine 的“全功能离线”定义为:
- 应用本身不建立网络连接。
- 不使用 CDN、远程字体、远程 Shader、WebView 在线页面或在线 API。
- 不在运行时下载 Qt、Runtime、图片解码器或其他依赖。
- 不上传用户资源、路径、日志或统计数据。
- 不包含自动更新和联网遥测。
- 用户已经准备好的匹配版本 Spine Editor 可在断网环境中完成转换。
Spine Editor 是外部前置软件。DateToSpine 不控制 Editor 自身的授权策略,但必须在断网验收中验证既有激活和已缓存版本能够完成流程。
## 2. 构建期与运行期网络
- 开发机可以联网取得依赖和官方测试资源。
- 正式依赖必须锁定版本和校验值。
- 可复现构建应支持从内部或本地依赖缓存构建。
- 最终应用运行时不得使用网络。
- CI 下载的官方测试资源不进入打包阶段的输入集合。
## 3. macOS 打包
目标:
- Apple Silicon 原生包。
- Intel 或 Universal 2 是否首发,由原型性能和构建环境决定。
- `.app``.dmg`
- Qt framework、platform plugin、image plugin、Runtime 插件和本地资源完整部署。
- 正式发行执行代码签名和 notarization;这是发布流程,不是最终应用运行时联网依赖。
应用不得修改用户的 Spine.app 或其缓存。
## 4. Windows 打包
目标:
- Windows 10/11 x64。
- MSVC 2022 v143。
- 使用 `/MD` 与 Qt 官方构建一致。
- 部署 MSVC Runtime、Qt platform plugin、imageformats 和 Runtime 插件。
- 安装包格式在原型后从 MSIX、WiX/MSI 或 Inno Setup 中确定。
- 正式发行建议代码签名。
最终用户不需要安装 CMake、Visual Studio 或 Qt SDK。
## 5. Runtime 许可
项目集成官方 Spine Runtimes 时必须遵守:
- [Spine Runtimes License Agreement](https://esotericsoftware.com/spine-runtimes-license)
- [Spine Editor License Agreement](https://esotericsoftware.com/spine-editor-license)
发行前需要法务或产品负责人确认最终分发模式。技术实现至少保证:
- `licenses/` 保存完整、未修改的 Runtime 许可文本。
- 安装包包含 Runtime 许可和版权声明。
- About/关于 页面提供第三方许可入口。
- 不隐藏用户需要合法 Spine 许可的前提。
- 不分发 Spine Editor、激活信息或 Editor 更新文件。
- Runtime 插件不作为通用 SDK 对外提供开发接口。
## 6. 官方测试资源
已确认规则:
- 官方 GitHub 仓库资源只用于开发、兼容性测试和 CI。
- 官方测试资源不进入最终安装包。
- 打包清单不包含 `tests/fixtures/external/`
- 发行检查使用路径、文件名和 SHA-256 禁止列表防止误打包。
- 测试报告可记录资源 case id 和哈希,但不嵌入资源文件。
## 7. 第三方依赖清单
每个依赖记录:
```text
name
version/commit
source URL
license
modified or unmodified
linked statically or dynamically
included platforms
notices path
```
至少包括:
- Qt 6
- Spine Runtimes 3.8/4.0/4.1/4.2/4.3
- 测试框架
- 可能引入的日志或打包依赖
新增依赖必须经过:离线可部署性、许可证、维护状态、体积和安全评审。
## 8. 网络禁止措施
- 不链接应用层 HTTP 客户端,除非未来需求重新评审。
- 资源加载器只接受本地文件协议。
- 代码扫描阻止 CDN URL 和动态依赖下载逻辑。
- 集成测试在网络阻断环境运行。
- 打包后执行连接监控测试。
文档中的官方链接仅供人阅读,不会被应用自动访问。
## 9. 发行目录白名单
打包采取白名单而不是“复制整个构建目录”:
```text
application executable
Qt runtime libraries/plugins
dts runtime plugins
icons/translations/shaders
licenses/notices
user documentation
```
明确排除:
```text
tests/
official sample assets
source repository metadata
developer caches
temporary recovery jobs
Spine Editor binaries
activation information
debug-only symbols(独立保存,不进入普通安装包)
```
+131
View File
@@ -0,0 +1,131 @@
# 开发计划
## 1. 开发原则
- 先验证风险,再建设完整 UI。
- 每个阶段有可运行结果和退出标准。
- 未通过技术关卡时不扩大代码量掩盖问题。
- 文档、测试和代码同步更新。
- 当前文档批准后才进入功能开发。
## 2. 阶段 0:技术原型
### 目标
验证决定产品能否成立的核心问题。
### 工作项
1. 在 macOS 上发现并验证已安装 Spine Editor。
2. 验证自定义 `.app`/可执行文件路径。
3. 调用 3.8、4.0、4.1、4.2、4.3 本地版本。
4. 为五个版本各构建最小 `spine-cpp` adapter。
5. 用 JSON、SKEL、Atlas 和纹理加载一帧预览数据。
6. 验证 QOpenGLWidget 绘制 Region、Mesh、Clipping 和混合模式。
7. 验证官方 Texture Unpacker。
8. 验证 SKEL → 临时 `.spine` → JSON → 设置 images → 最终 `.spine`
9. 验证断网运行。
10. 在 Windows/MSVC 至少完成构建和 Editor CLI 冒烟测试。
### 技术关卡
- 五套 Runtime 可以隔离构建并通过统一 ABI 加载。
- 五个版本至少各有一个 JSON/SKEL 可预览。
- 本地 Editor 路径和版本能可靠识别。
- JSON/SKEL 可以生成可回读 `.spine`
- 图片路径能自动关联,或明确证明需要采用哪种降级方案。
- 官方解包和 PMA 行为被记录。
- 断网时已准备版本可执行完整流程。
任何关卡失败都先更新架构决策,不进入大规模 UI 开发。
## 3. 阶段 1:工程骨架与领域模型
- CMake Presets 和平台 toolchain。
- 目录、target 和第三方依赖边界。
- `AssetGroup``SpineVersion``RecoveryJob`
- 文件系统、进程和报告接口。
- 基础错误码和状态机。
- 单元测试框架和 CI 基线。
- 自研代码行数检查。
退出标准:macOS/Windows Debug 与 Release 构建通过,核心模型测试通过。
## 4. 阶段 2:扫描、配对与预检
- 递归扫描。
- JSON/SKEL 版本识别。
- Atlas parser。
- 页面解析和图片校验。
- 资源组配对评分。
- 手动修正模型。
- Unicode、长路径和安全边界。
退出标准:官方样例和异常样例能形成确定、可解释的资源组。
## 5. 阶段 3:离线预览器
- 五个 Runtime 插件完整实现。
- Texture Provider。
- OpenGL 批次渲染。
- 动画、Skin、Seek、速度控制。
- 调试显示。
- 视觉回归测试。
退出标准:五个版本功能矩阵通过,拔网预览正常。
## 6. 阶段 4Editor 管理与转换
- macOS/Windows 自动发现。
- 自定义路径 UI。
- 版本能力映射。
- Spine CLI 封装。
- JSON/SKEL 导入。
- 图片路径规范化。
- 语义回读验证。
- 原子发布和恢复报告。
退出标准:五个版本端到端转换通过,输入未改变。
## 7. 阶段 5Atlas 解包
- 官方解包器唯一流程。
- 官方结果验证。
- PMA、Scale、rotate、offset、多页和 polygon packing。
- PNG/JPG/WebP 测试。
退出标准:官方解包与失败原因可观察、可报告,且各版本具有视觉回归。
## 8. 阶段 6:批处理和用户体验
- 任务队列、取消和失败隔离。
- 批量重名策略。
- 进度、日志和错误建议。
- 设置迁移和会话恢复。
- 中英文界面。
- 无障碍和高 DPI 检查。
## 9. 阶段 7:发布准备
- macOS/Windows 安装包。
- Qt 和图片插件部署。
- Runtime 许可与 Third-Party Notices。
- 官方测试资源排除检查。
- 离线端到端测试。
- 签名、公证和干净机器安装测试。
- 性能、内存和损坏输入测试。
## 10. 建议评审点
每阶段完成后提交:
- 功能演示
- 自动测试结果
- 新增风险
- 文件长度报告
- 依赖与许可变更
- 下一阶段范围
## 11. 编码顺序建议
开始开发后的第一个提交不应是完整项目 UI,而应是阶段 0 的最小原型和实验记录。技术原型通过后,再固化公共接口和正式目录。
@@ -0,0 +1,102 @@
# 风险与架构决策
## 1. 风险台账
| ID | 风险 | 影响 | 应对 |
|---|---|---|---|
| R-01 | 不同 Spine 版本二进制格式不兼容 | 错误预览或导入失败 | 五套匹配 Runtime;Editor 主/次版本强校验 |
| R-02 | 3.8 早期格式变化 | 部分 3.8 文件无法读取 | 正式范围从 3.8.20 开始;旧文件尽力识别 |
| R-03 | SKEL 不含可直接修改的图片路径 | 生成工程显示 Missing | Editor 双重导入/导出原型;输出布局降级方案 |
| R-04 | 五套 Runtime 符号/API 冲突 | 无法链接或运行不稳定 | 独立动态模块、隐藏符号、稳定 C ABI |
| R-05 | 损坏输入使 Runtime 崩溃 | 主程序退出 | 有界预检、模糊测试;必要时改 helper process |
| R-06 | Spine Launcher 缺少目标缓存版本 | 离线转换失败 | 版本能力预检;不自动下载;明确修复提示 |
| R-07 | Editor 自身的联网或授权行为 | 离线验收失败 | 真正断网测试;明确前置条件;不修改或绕过 Editor |
| R-08 | PMA/WebP 边缘错误 | 预览或解包出现色边 | 格式矩阵与 Alpha 视觉回归;Alpha 处理交给官方解包器 |
| R-09 | Polygon-packed Mesh 难以还原原图 | 图片包含邻近像素或裁切错误 | 仅使用官方解包器;失败时报告限制并终止 |
| R-10 | 官方测试素材误入安装包 | 许可与体积风险 | 打包白名单、哈希禁止清单、发行物解包检查 |
| R-11 | Qt/Runtime 许可遗漏 | 无法合规发行 | 依赖清单、licenses、About 页面、发行关卡 |
| R-12 | 自定义路径和 Unicode 处理错误 | Editor 无法启动 | 参数数组、canonical path、双平台路径测试 |
| R-13 | 巨大资源耗尽内存 | 卡死或崩溃 | 输入限制、预算检查、按需解码、可取消任务 |
| R-14 | 跨补丁 Editor 行为变化 | 同一主/次版本结果不同 | 记录实际补丁;固定测试矩阵;报告转换环境 |
## 2. 已接受架构决策
### ADR-001:生成 `.spine` 使用官方 Editor
- 状态:接受
- 决策:调用本地已授权 Spine Editor,不自行写 `.spine`
- 原因:`.spine` 是编辑工程格式;官方 CLI 已提供数据导入能力。
### ADR-002:版本范围为 3.8.20+ 至 4.3
- 状态:接受
- 决策:3.8.20+、4.0、4.1、4.2、4.3;不支持 3.8 以下。
- 原因:覆盖目标游戏资源,同时控制旧格式维护成本。
### ADR-003:预览进入首版
- 状态:接受
- 决策:用户确认转换前必须可离线预览。
- 原因:避免批量生成后才发现配对、Scale、Alpha 或版本错误。
### ADR-004:每个主/次版本独立 Runtime
- 状态:接受
- 决策:五个 Runtime 插件,通过统一 C ABI 接入。
- 原因:官方要求 Runtime 与数据主/次版本同步,API 也会变化。
### ADR-005:仅使用官方 Atlas 解包
- 状态:接受
- 决策:官方 Texture Unpacker 是唯一纹理解包实现;失败时任务失败。
- 原因:官方对自身 Atlas、PMA、旋转和多边形打包语义最权威,且软件以前置正版 Spine 为使用条件。
### ADR-006:运行期全离线
- 状态:接受
- 决策:无 CDN、下载、遥测、资源上传和在线 API。
- 原因:资源敏感性、稳定性和用户要求。
### ADR-007:官方测试资源不发行
- 状态:接受
- 决策:官方资源只用于开发/CI,最终安装包不包含。
- 原因:用户明确要求,并减少许可与安装体积风险。
### ADR-008Qt 6 + CMake + MSVC/Apple Clang
- 状态:接受
- 决策:主程序 C++20CMake 3.25+Windows MSVC v143。
- 原因:跨平台 UI、进程管理、图片插件和成熟构建生态。
## 3. 原型后待决策事项
### ADR-P01Runtime 插件还是 helper process
默认先验证动态插件。若损坏文件可导致不可接受的进程级崩溃,则采用 helper process 隔离。
### ADR-P02macOS 架构
首发 Apple Silicon 或 Universal 2,根据目标用户和五套 Runtime 构建成本决定。
### ADR-P03:测试框架
Qt Test 与 Catch2 二选一,以 Runtime 插件和参数化兼容矩阵的便利性为主要标准。
### ADR-P04SKEL 图片路径自动化方案
由 Editor 双重导入/导出原型结果决定正式流程及是否存在可接受降级。
### ADR-P05Windows 安装器
在 MSIX、WiX/MSI、Inno Setup 中选择,需满足离线安装、签名和干净卸载。
## 4. 决策变更规则
变更已接受决策时必须记录:
- 变更原因
- 新证据或测试结果
- 对范围、兼容性和许可的影响
- 数据迁移或设置迁移方案
- 负责人和日期
@@ -0,0 +1,93 @@
# 阶段 0 技术原型记录
## 记录日期
2026-09-07
## 当前结论
首批工程骨架已经建立并在 macOS Apple Silicon 上通过构建测试。此阶段证明多版本 Runtime 隔离、基础输入识别、本地 Editor 发现和跨平台进程抽象可以按既定架构继续推进;尚未证明完整动画预览或 `.spine` 恢复流程。
## 本机环境
- Apple Clang 17
- CMake 4.4.3(项目最低要求仍为 3.25)
- Ninja 1.13.2
- Qt Base 6.11.2
- Qt Image Formats 6.11.2
- Spine Launcher 4.3.06 Apple Silicon
- Launcher 当前观察到启动 Spine Editor 3.8.99 Professional
只安装 Qt Base 与 Qt Image Formats,没有安装 Qt WebEngine、Location、NetworkAuth 等与产品无关的 Qt 元组件。
## 固定 Runtime 提交
| Runtime | 官方分支 | 提交 |
|---|---|---|
| 3.8 | `3.8` | `8b4844bd4b193ba9e54487ed397a777993cbad56` |
| 4.0 | `4.0` | `425ce416bb218b28caeec47b317aa57cd7140375` |
| 4.1 | `4.1` | `77a5db0ec6d16331f5efbaa7662bba9355bd3424` |
| 4.2 | `4.2` | `e7dc1435fa4a0083ab431f1b28e083c14a1f5c68` |
| 4.3 | `4.3` | `4309c05c287d3f15da778e68f5d2a483fe10a6a3` |
当前只展开各分支的 `spine-cpp` 源码。4.3 官方 Spineboy 资源仅用于开发测试,不进入安装目标和发行包。
## 已实现
- CMake/C++20 工程和 macOS、Windows Preset。
- 标准 C++ 核心库,不依赖 Qt。
- Spine 版本值对象和正式支持范围判断。
- JSON 最小 Spine schema 与版本探测。
- SKEL 二进制头部 hash/version 有界读取。
- Atlas 多页、page、region、PMA、scale、rotate、bounds/legacy 字段基础解析。
- macOS 常见 Spine 路径发现和 `.app` 规范化。
- Windows 常见路径骨架和 `.exe``.com` 规范化。
- 不经过 shell 的 POSIX/Windows 子进程执行器。
- 超时后终止本软件启动的整个子进程树。
- Launcher 版本与实际 Editor 版本分离解析。
- 五个 Runtime 独立静态目标和动态 adapter。
- 稳定 C ABI 版本与 metadata 加载验证。
- 极简 Qt 桌面外壳:资源拖放/选择、资源切换、动画切换和一键转换。
- Editor 自动发现;仅在自动发现失败时请求自定义路径。
- 4.3 Runtime 已实际加载官方 JSON、SKEL 和 Atlas,并输出动画/Skin/骨骼元数据。
- 4.3 Runtime 已输出裁剪后的纹理三角形,Qt 画布可循环播放并切换真实动画。
- 4.3 beta 后缀版本和新版 SKEL 文件头识别。
- CLI:Editor 发现、路径探测、数据版本检查。
## 构建与测试结果
- `cmake --preset macos-debug`:通过。
- `cmake --build --preset macos-debug`:通过。
- 五个官方 Runtime:通过 Apple Clang 编译。
- 五个 Runtime adapter:通过动态加载和 ABI/版本检查。
- 核心单元测试:全部通过。
- 官方 Spineboy JSON/SKEL 真实加载测试:通过,识别到 11 个动画。
- Qt 应用 offscreen 启动冒烟测试:通过。
Spine 4.3 上游源码在 Apple Clang 下产生一条未覆盖枚举值的 warning。第三方源码没有被修改,该警告暂不影响构建,后续记录在上游警告基线中。
## 实际 Editor 探测发现
直接执行本机 `Spine --version` 时先报告 Launcher 4.3.06,随后启动 3.8.99 Professional。旧版 launcher 在当前系统产生字体兼容警告,并且探测可能长时间不退出。
因此代码已经采用:
- 分离解析 Launcher 和 Editor 版本。
- 探测超时。
- 进程树终止。
- “已观察到 Spine 但探测超时”独立状态。
仅看到 Launcher 版本不能代表目标 Editor 版本可用。
## 尚未完成的阶段 0 关卡
1. 对 3.8—4.2 执行真实资源预览。
2. 目标 Editor 版本选择与离线缓存能力验证。
3. 官方 Texture Unpacker 调用与结果验证。
4. JSON/SKEL 到 `.spine` 的端到端恢复。
5. SKEL 图片路径双重导入/导出方案验证。
6. Windows MSVC 构建与真实 Editor 测试。
## 下一步
先扩展 Runtime C ABI,以 4.3 adapter 实现真实资源加载、动画/Skin 元数据与 draw list;通过后把版本差异分别适配到 4.2、4.1、4.0 和 3.8。随后接 Qt OpenGL 预览画布,再进入 Editor 导入和官方解包流水线。
@@ -0,0 +1,26 @@
# 极简界面阶段记录
## 交互结论
主界面已经收敛为“拖入、预览、切换动画、一键转换”。版本号、文件格式、校验状态和 Editor 管理等技术信息不再常驻显示。
## 当前可查看产出
- 空状态支持拖入 `.json``.skel``.atlas`、纹理或整个目录。
- 多套资源通过顶部单一选择框切换。
- 4.3 官方 Spineboy JSON/SKEL 可读取,动画选择框显示 11 个真实动画。
- 当前画布已使用官方 4.3 Runtime 绘制真实骨骼姿态,并默认循环播放 `idle`
- 动画下拉框会实际切换 Runtime 动画,不再只是显示名称。
- “一键转换为 .spine”按钮已接入本地 Spine Editor 自动发现和异步导入入口。
- 自动发现 Editor 失败时才弹出路径选择,不再提供独立管理窗口。
- 软件没有网络请求。
界面截图:`阶段产出/极简界面-资源已载入.png`
## 尚未宣称完成
- 当前真实动画渲染已覆盖 4.3,3.8—4.2 尚未接入统一绘制接口。
- 一键转换入口已形成,但官方解包、图片路径恢复和生成物回读验证尚未串成最终流水线。
- 3.8—4.2 Runtime 仍需补齐真实数据加载和绘制接口。
这些未完成项不会通过增加设置页面暴露给用户,仍保持后台自动化和极简主流程。
@@ -0,0 +1,40 @@
# 4.3 动画预览阶段记录
## 阶段结果
4.3 官方 Runtime 已从“只读资源信息”升级为真实动画绘制。主界面现在会直接显示骨骼角色,而不是整张 Atlas 纹理。
## 已实现
- Runtime ABI 增加动画选择、逐帧更新和绘制命令接口。
- 4.3 adapter 创建 `Skeleton``AnimationState``SkeletonRenderer`
- 输出世界坐标、UV、颜色、索引、纹理路径和混合模式。
- Runtime 内部完成 Mesh 和 Clipping 处理。
- Qt 独立预览画布按纹理三角形绘制并自动适应窗口。
- 支持 Normal、Additive、Multiply、Screen 四种混合模式映射。
- 默认循环播放动画列表第一项;列表为空时保持静态姿态,不启动动画播放。
- 用户切换下拉框时立即切换动画并循环播放。
- 预览画布尺寸独立于每帧骨骼边界;首次加载后锁定视口,动画过程不再忽大忽小。
- 默认以画布可容纳的最大比例显示,并保留少量安全边距。
- 滚动鼠标中键时,以鼠标所在位置为中心缩放画布。
- 单击鼠标中键时重置缩放和画布位置。
- 按住鼠标右键拖动时平移画布;右键短按时回到中心。
- 软件绘制路径消除了逐三角形抗锯齿造成的 Mesh 接缝。
## 测试证据
官方 Spineboy 4.3 JSON 测试已经验证:
- 动画可被选择。
- 每帧能产生绘制命令、顶点和三角形索引。
- 绘制命令引用的纹理文件存在。
- 时间推进后世界坐标确实发生变化。
- JSON 和 SKEL 仍能重复加载。
阶段截图:`阶段产出/骨骼动画预览-4.3.png`
固定画布截图:`阶段产出/固定画布最大显示-4.3.png`
## 后续范围
下一步复用相同 ABI 分别适配 4.2、4.1、4.0 和 3.8。各版本仍使用独立官方 Runtime,不把跨版本差异塞入界面代码。
@@ -0,0 +1,81 @@
# 离线转换与多版本回归记录
## 1. 本阶段结果
- 资源载入完成后自动检测匹配版本线,并在顶部显示是否可转换。
- 4.3 资源在本机使用已缓存的 Spine `4.3.23`,不再把资源导出补丁号直接传给 Launcher。
- 只传精确缓存版本;禁止 `latest`、通配版本和 `--force`
- 缓存缺失时不启动 Spine,转换任务内部也会二次拦截。
- 成功和失败都显示弹窗;失败弹窗包含具体阶段与进程输出摘要。
- 最终结果只包含 `.spine``images/`,不包含原始 Atlas 或打包纹理页。
- 底部增加“去除预乘 Alpha”开关,默认关闭。
界面产出:[离线转换与去预乘开关.png](阶段产出/离线转换与去预乘开关.png)
## 2. 输出结构
```text
选择的输出位置/
spineboy-pro/
spineboy-pro.spine
images/
head.png
torso.png
...
```
同名目录存在时使用 `-2``-3` 后缀。所有内容先写入同一输出磁盘的临时目录,校验成功后再整体发布;失败不会留下伪成功目录。
## 3. Alpha 处理
官方 CLI 没有独立的去预乘开关,而是读取 Atlas 的 `pma` 值。为让 3.8—4.3 具有统一、可控的行为:
1. 原始 Atlas 与纹理页始终只读。
2. 开启开关时原样传入 Atlas,由官方 Texture Unpacker 根据 `pma` 完成转换。
3. 关闭开关时建立临时 Atlas 元数据副本,将 `pma:true` 改为 `pma:false`,再由官方解包器保留打包像素。
4. 不使用 Qt 对输出图片做逐像素变换或重新编码。
5. 临时 Atlas 随任务工作区清理,不进入结果目录。
## 4. 工程图片尺寸问题
已确认根因不是官方解包器压缩图片。测试 Atlas 声明 `scale: 0.5`,官方解包得到的 `head.png` 为 136×149、`front-shin.png` 为 41×92,符合实际打包尺寸。旧流程导入骨骼数据时没有传 Atlas Scale,导致附件坐标保持原始尺寸,而图片只有打包尺寸,于是局部图片缩小、脱节。
修复后的工程导入读取 Atlas 的 `scale`,再增加 `-s <数值>`,由匹配版本 Spine Editor 统一缩放骨骼、附件与动画数据。使用数值可以兼容不接受 Atlas 路径参数的 4.0 等旧版 Editor。
## 5. 多版本预览回归
每条版本线使用对应官方 `spine-cpp` 和该分支的 Spineboy 开发样本。样本仅用于开发测试,不进入安装包。
| 版本 | JSON | SKEL | 动画选择 | 绘制帧 |
|---|---|---|---|---|
| 3.8 | 通过 | 通过 | 通过 | 通过 |
| 4.0 | 通过 | 通过 | 通过 | 通过 |
| 4.1 | 通过 | 通过 | 通过 | 通过 |
| 4.2 | 通过 | 通过 | 通过 | 通过 |
| 4.3 | 通过 | 通过 | 通过 | 通过 |
3.8—4.1 共享一个旧版绘制适配层,4.2 和 4.3 使用各自 Runtime 自带的 `SkeletonRenderer`,避免将版本差异堆叠到主界面代码。
### 5.1 旧版 UV 方向修正
3.8—4.2 的 Runtime 输出沿用 Y 轴向上的世界坐标,而当前 Qt 画布以左上角为原点。适配层在输出预览顶点时对这些版本执行 `y = -y`;旧版 UV 已经与 Runtime 的三角形顶点顺序配套,不能再次翻转。4.3 已符合当前预览器方向,不执行该转换。
修复后截图:[旧版预览方向修复-4.2.png](阶段产出/旧版预览方向修复-4.2.png)
## 6. 本机实际转换回归
所有命令均选择已经存在的精确缓存版本:
| 资源线 | 本地 Editor | JSON 导入 | SKEL 导入 | 解包图片数 |
|---|---:|---:|---:|---:|
| 3.8 | 3.8.99 | 前一轮通过;本轮缓存不可用 | 前一轮通过;本轮缓存不可用 | 40(前一轮) |
| 4.0 | 4.0.64 | 55,295 字节 | 58,616 字节 | 40 |
| 4.1 | 4.1.24 | 55,483 字节 | 58,908 字节 | 40 |
| 4.2 | 4.2.43 | 50,811 字节 | 54,940 字节 | 40 |
| 4.3 | 4.3.23 | 49,603 字节 | 49,905 字节 | 40 |
4.2 A/B 回导验证:未传比例时 `hip.y=247.27`、前胫骨长度为 `128.77`;传 `-s 0.5` 后分别为 `123.64``64.39`,Mesh 顶点也同步减半,而图片维持官方解包尺寸。这说明工程数据与图片比例已恢复一致。
本轮 3.8 缓存文件在回归前已不可用,ARM Launcher 无法加载旧 x86 版本,因此遵守离线约束,没有尝试联网补齐。3.8 官方样本不声明 Atlas `scale`,修复路径会使用 `1.0`,与原导入行为一致;待本机重新具备可运行的 3.8 Editor 后仍需补跑本轮实机导入。
自动测试与完整构建通过。官方测试资源仍位于开发期 `third_party` 目录,安装规则只复制 Runtime 许可文件。
+61
View File
@@ -0,0 +1,61 @@
# DateToSpine 开发文档
## 文档状态
- 状态:技术原型开发中,极简主界面已形成
- 文档基线:2026-09-07
- 当前目录包含设计文档、C++/Qt 工程、Runtime 适配器和自动测试
## 产品概述
DateToSpine 是一个面向 macOS 和 Windows 的离线桌面工具,用于扫描、预览并批量恢复 Spine 游戏运行时资源:
```text
.skel/.json + .atlas/.atlas.txt + texture pages
可编辑的 .spine 工程
```
软件内置匹配版本的官方 `spine-cpp` Runtime 以实现转换前预览。生成 `.spine` 时,用户电脑必须已经安装、激活并准备好与资源版本匹配的 Spine Editor。
## 已确认的产品决策
1. 正式支持 Spine `3.8.20+``4.0.x``4.1.x``4.2.x``4.3.x`
2. 不支持 Spine 3.8 以下版本。
3. 转换前必须支持离线预览,用户确认后才生成 `.spine`
4. 五个 Spine 主/次版本使用各自对应的官方 Runtime。
5. 生成 `.spine` 必须调用本地已授权的 Spine Editor,不自行编写或逆向 `.spine` 文件。
6. Atlas 纹理只使用 Spine 官方 Texture Unpacker 解包,不提供自研兜底。
7. 软件运行期不使用 CDN、在线 API、在线依赖下载、遥测或资源上传。
8. 官方测试资源仅用于开发和 CI 测试,不进入最终安装包。
9. 最终安装包只携带 Runtime 许可、第三方许可和必要声明,不携带 Spine Editor 或官方示例资源。
10. 自研源文件按职责拆分,禁止出现数千行的业务代码文件。
11. 主界面只保留拖入资源、预览/动画切换和一键转换;仅在 Editor 自动识别失败时请求路径。
## 文档索引
- [01-产品需求.md](01-产品需求.md):产品范围、用户流程和验收标准
- [02-系统架构.md](02-系统架构.md):技术架构、模块边界和代码约束
- [03-版本识别与编辑器检测.md](03-版本识别与编辑器检测.md):版本识别、本地 Editor 检测和自定义路径
- [04-恢复流水线.md](04-恢复流水线.md):恢复流水线、输出规则和错误处理
- [05-离线预览器.md](05-离线预览器.md):多版本 Runtime 与离线预览器
- [06-图集解包设计.md](06-图集解包设计.md):官方唯一解包、PMA 与 Atlas Scale 设计
- [07-测试策略.md](07-测试策略.md):测试矩阵、官方测试资源管理和验收
- [08-离线打包与许可.md](08-离线打包与许可.md):离线、发行与许可要求
- [09-开发计划.md](09-开发计划.md):阶段计划、技术关卡与完成定义
- [10-风险与架构决策.md](10-风险与架构决策.md):风险台账与架构决策记录
- [11-阶段0技术原型记录.md](11-阶段0技术原型记录.md):首批代码、构建证据和后续关卡
- [12-极简界面阶段记录.md](12-极简界面阶段记录.md):精简后的界面范围、当前产出与未完成项
- [13-四点三动画预览记录.md](13-四点三动画预览记录.md):4.3 真实骨骼绘制、动画切换和测试证据
- [14-离线转换与多版本回归记录.md](14-离线转换与多版本回归记录.md):本地版本门禁、完整输出、PMA 开关和 3.8—4.3 回归证据
## 官方参考资料
- [Spine Command Line Interface](https://en.esotericsoftware.com/spine-command-line-interface)
- [Importing skeleton data](https://esotericsoftware.com/blog/Importing-skeleton-data)
- [Spine versioning](https://esotericsoftware.com/spine-versioning)
- [spine-cpp Runtime Documentation](https://esotericsoftware.com/spine-cpp)
- [Spine JSON format](https://esotericsoftware.com/spine-json-format)
- [Spine binary format](https://esotericsoftware.com/spine-binary-format)
- [Spine Runtimes repository](https://github.com/EsotericSoftware/spine-runtimes)
- [Spine Runtimes License](https://esotericsoftware.com/spine-runtimes-license)
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

+90
View File
@@ -0,0 +1,90 @@
#pragma once
#include <stdint.h>
#if defined(_WIN32)
#define DTS_RUNTIME_EXPORT __declspec(dllexport)
#else
#define DTS_RUNTIME_EXPORT __attribute__((visibility("default")))
#endif
#ifdef __cplusplus
extern "C" {
#endif
enum { DTS_RUNTIME_API_VERSION = 3 };
enum DtsRuntimeCapability {
DTS_RUNTIME_CAP_SKELETON_METADATA = 1u << 0,
DTS_RUNTIME_CAP_DRAW_COMMANDS = 1u << 1
};
typedef struct DtsRuntimeInfo {
uint32_t structSize;
uint32_t apiVersion;
const char* spineVersionLine;
const char* adapterBuild;
uint32_t capabilities;
} DtsRuntimeInfo;
typedef void* DtsRuntimeHandle;
typedef struct DtsLoadRequest {
uint32_t structSize;
const char* skeletonPathUtf8;
const char* atlasPathUtf8;
float scale;
} DtsLoadRequest;
typedef struct DtsSkeletonInfo {
uint32_t structSize;
uint32_t boneCount;
uint32_t slotCount;
uint32_t skinCount;
uint32_t animationCount;
uint32_t eventCount;
uint32_t constraintCount;
} DtsSkeletonInfo;
typedef struct DtsRenderVertex {
float x;
float y;
float u;
float v;
uint32_t color;
} DtsRenderVertex;
typedef struct DtsDrawCommand {
uint32_t structSize;
const DtsRenderVertex* vertices;
uint32_t vertexCount;
const uint16_t* indices;
uint32_t indexCount;
const char* texturePathUtf8;
uint32_t blendMode;
} DtsDrawCommand;
DTS_RUNTIME_EXPORT uint32_t dtsRuntimeGetApiVersion(void);
DTS_RUNTIME_EXPORT int32_t dtsRuntimeGetInfo(DtsRuntimeInfo* info);
DTS_RUNTIME_EXPORT DtsRuntimeHandle dtsRuntimeCreate(void);
DTS_RUNTIME_EXPORT void dtsRuntimeDestroy(DtsRuntimeHandle handle);
DTS_RUNTIME_EXPORT int32_t dtsRuntimeLoad(DtsRuntimeHandle handle, const DtsLoadRequest* request);
DTS_RUNTIME_EXPORT int32_t dtsRuntimeGetSkeletonInfo(
DtsRuntimeHandle handle, DtsSkeletonInfo* info);
DTS_RUNTIME_EXPORT const char* dtsRuntimeGetAnimationName(
DtsRuntimeHandle handle, uint32_t index);
DTS_RUNTIME_EXPORT float dtsRuntimeGetAnimationDuration(
DtsRuntimeHandle handle, uint32_t index);
DTS_RUNTIME_EXPORT const char* dtsRuntimeGetSkinName(
DtsRuntimeHandle handle, uint32_t index);
DTS_RUNTIME_EXPORT const char* dtsRuntimeGetLastError(DtsRuntimeHandle handle);
DTS_RUNTIME_EXPORT int32_t dtsRuntimeSetAnimation(
DtsRuntimeHandle handle, const char* animationNameUtf8, int32_t loop);
DTS_RUNTIME_EXPORT int32_t dtsRuntimeUpdate(DtsRuntimeHandle handle, float deltaSeconds);
DTS_RUNTIME_EXPORT uint32_t dtsRuntimeGetDrawCommandCount(DtsRuntimeHandle handle);
DTS_RUNTIME_EXPORT int32_t dtsRuntimeGetDrawCommand(
DtsRuntimeHandle handle, uint32_t index, DtsDrawCommand* command);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,305 @@
#include "RuntimeApi.h"
#include <spine/Animation.h>
#include <spine/AnimationState.h>
#include <spine/AnimationStateData.h>
#include <spine/Atlas.h>
#include <spine/ClippingAttachment.h>
#include <spine/Extension.h>
#include <spine/MeshAttachment.h>
#include <spine/RegionAttachment.h>
#include <spine/Skeleton.h>
#include <spine/SkeletonBinary.h>
#include <spine/SkeletonClipping.h>
#include <spine/SkeletonData.h>
#include <spine/SkeletonJson.h>
#include <spine/Skin.h>
#include <spine/Slot.h>
#include <spine/SlotData.h>
#include <spine/TextureLoader.h>
#include <algorithm>
#include <cctype>
#include <cstddef>
#include <memory>
#include <string>
#include <vector>
#ifndef DTS_SPINE_LINE
#error DTS_SPINE_LINE must be defined before including LegacyRuntimeAdapter.inl
#endif
namespace {
struct TextureRecord { std::string path; };
class MetadataTextureLoader final : public spine::TextureLoader {
public:
void load(spine::AtlasPage& page, const spine::String& path) override {
#if defined(DTS_SPINE_41)
page.texture = new TextureRecord{path.buffer()};
#else
page.setRendererObject(new TextureRecord{path.buffer()});
#endif
}
void unload(void* texture) override { delete static_cast<TextureRecord*>(texture); }
};
struct RenderBatch {
std::vector<DtsRenderVertex> vertices;
std::vector<std::uint16_t> indices;
std::string texturePath;
std::uint32_t blendMode = 0;
};
struct RuntimeContext {
MetadataTextureLoader textureLoader;
std::unique_ptr<spine::Atlas> atlas;
std::unique_ptr<spine::SkeletonData> skeletonData;
std::unique_ptr<spine::Skeleton> skeleton;
std::unique_ptr<spine::AnimationStateData> animationStateData;
std::unique_ptr<spine::AnimationState> animationState;
spine::SkeletonClipping clipper;
std::vector<std::string> animationNames;
std::vector<float> animationDurations;
std::vector<std::string> skinNames;
std::vector<RenderBatch> renderBatches;
std::string error;
};
RuntimeContext* context(DtsRuntimeHandle handle) { return static_cast<RuntimeContext*>(handle); }
std::string lowercaseExtension(const std::string& path) {
const auto dot = path.find_last_of('.');
auto extension = dot == std::string::npos ? std::string{} : path.substr(dot);
std::transform(extension.begin(), extension.end(), extension.begin(),
[](unsigned char value) { return static_cast<char>(std::tolower(value)); });
return extension;
}
void collectMetadata(RuntimeContext& value) {
value.animationNames.clear();
value.animationDurations.clear();
auto& animations = value.skeletonData->getAnimations();
for (std::size_t index = 0; index < animations.size(); ++index) {
value.animationNames.emplace_back(animations[index]->getName().buffer());
value.animationDurations.push_back(animations[index]->getDuration());
}
value.skinNames.clear();
auto& skins = value.skeletonData->getSkins();
for (std::size_t index = 0; index < skins.size(); ++index)
value.skinNames.emplace_back(skins[index]->getName().buffer());
}
spine::AtlasRegion* regionFor(spine::RegionAttachment& attachment) {
#if defined(DTS_SPINE_41)
return static_cast<spine::AtlasRegion*>(attachment.getRegion());
#else
return static_cast<spine::AtlasRegion*>(attachment.getRendererObject());
#endif
}
spine::AtlasRegion* regionFor(spine::MeshAttachment& attachment) {
#if defined(DTS_SPINE_41)
return static_cast<spine::AtlasRegion*>(attachment.getRegion());
#else
return static_cast<spine::AtlasRegion*>(attachment.getRendererObject());
#endif
}
std::uint32_t packedColor(spine::Skeleton& skeleton, spine::Slot& slot,
spine::Color& attachment) {
const auto component = [](float value) {
return static_cast<std::uint32_t>(std::clamp(value, 0.0F, 1.0F) * 255.0F);
};
const auto& skeletonColor = skeleton.getColor();
const auto& slotColor = slot.getColor();
const auto red = component(skeletonColor.r * slotColor.r * attachment.r);
const auto green = component(skeletonColor.g * slotColor.g * attachment.g);
const auto blue = component(skeletonColor.b * slotColor.b * attachment.b);
const auto alpha = component(skeletonColor.a * slotColor.a * attachment.a);
return (alpha << 24) | (red << 16) | (green << 8) | blue;
}
void appendBatch(RuntimeContext& value, spine::Slot& slot, spine::Color& color,
spine::AtlasRegion* region, spine::Vector<float>& positions,
spine::Vector<float>& uvs, spine::Vector<unsigned short>& indices) {
if (!region || !region->page) return;
#if defined(DTS_SPINE_41)
auto* texture = static_cast<TextureRecord*>(region->page->texture);
#else
auto* texture = static_cast<TextureRecord*>(region->page->getRendererObject());
#endif
if (!texture) return;
auto* sourcePositions = &positions;
auto* sourceUvs = &uvs;
auto* sourceIndices = &indices;
if (value.clipper.isClipping()) {
value.clipper.clipTriangles(positions, indices, uvs, 2);
sourcePositions = &value.clipper.getClippedVertices();
sourceUvs = &value.clipper.getClippedUVs();
sourceIndices = &value.clipper.getClippedTriangles();
}
RenderBatch batch;
const auto vertexCount = sourcePositions->size() / 2;
batch.vertices.reserve(vertexCount);
const auto packed = packedColor(*value.skeleton, slot, color);
for (std::size_t index = 0; index < vertexCount; ++index)
batch.vertices.push_back({(*sourcePositions)[index * 2], -(*sourcePositions)[index * 2 + 1],
(*sourceUvs)[index * 2], (*sourceUvs)[index * 2 + 1], packed});
batch.indices.assign(sourceIndices->buffer(), sourceIndices->buffer() + sourceIndices->size());
batch.texturePath = texture->path;
batch.blendMode = static_cast<std::uint32_t>(slot.getData().getBlendMode());
value.renderBatches.push_back(std::move(batch));
}
void collectDrawCommands(RuntimeContext& value) {
value.renderBatches.clear();
spine::Vector<float> positions;
spine::Vector<unsigned short> quadIndices;
quadIndices.add(0); quadIndices.add(1); quadIndices.add(2);
quadIndices.add(2); quadIndices.add(3); quadIndices.add(0);
auto& drawOrder = value.skeleton->getDrawOrder();
for (std::size_t index = 0; index < drawOrder.size(); ++index) {
auto& slot = *drawOrder[index];
auto* attachment = slot.getAttachment();
if (!attachment) {
value.clipper.clipEnd(slot);
continue;
}
if (attachment->getRTTI().isExactly(spine::RegionAttachment::rtti)) {
auto& region = *static_cast<spine::RegionAttachment*>(attachment);
positions.setSize(8, 0);
#if defined(DTS_SPINE_41)
region.computeWorldVertices(slot, positions, 0, 2);
#else
region.computeWorldVertices(slot.getBone(), positions, 0, 2);
#endif
appendBatch(value, slot, region.getColor(), regionFor(region),
positions, region.getUVs(), quadIndices);
} else if (attachment->getRTTI().isExactly(spine::MeshAttachment::rtti)) {
auto& mesh = *static_cast<spine::MeshAttachment*>(attachment);
positions.setSize(mesh.getWorldVerticesLength(), 0);
mesh.computeWorldVertices(slot, 0, mesh.getWorldVerticesLength(),
positions.buffer(), 0, 2);
appendBatch(value, slot, mesh.getColor(), regionFor(mesh),
positions, mesh.getUVs(), mesh.getTriangles());
} else if (attachment->getRTTI().isExactly(spine::ClippingAttachment::rtti)) {
value.clipper.clipStart(slot, static_cast<spine::ClippingAttachment*>(attachment));
}
value.clipper.clipEnd(slot);
}
value.clipper.clipEnd();
}
} // namespace
namespace spine {
SpineExtension* getDefaultExtension() { return new DefaultSpineExtension(); }
} // namespace spine
extern "C" {
uint32_t dtsRuntimeGetApiVersion() { return DTS_RUNTIME_API_VERSION; }
int32_t dtsRuntimeGetInfo(DtsRuntimeInfo* info) {
if (!info || info->structSize < sizeof(DtsRuntimeInfo)) return -1;
info->apiVersion = DTS_RUNTIME_API_VERSION;
info->spineVersionLine = DTS_SPINE_LINE;
info->adapterBuild = "preview-1";
info->capabilities = DTS_RUNTIME_CAP_SKELETON_METADATA | DTS_RUNTIME_CAP_DRAW_COMMANDS;
return 0;
}
DtsRuntimeHandle dtsRuntimeCreate() { return new RuntimeContext; }
void dtsRuntimeDestroy(DtsRuntimeHandle handle) { delete context(handle); }
int32_t dtsRuntimeLoad(DtsRuntimeHandle handle, const DtsLoadRequest* request) {
auto* value = context(handle);
if (!value || !request || request->structSize < sizeof(DtsLoadRequest)
|| !request->skeletonPathUtf8 || !request->atlasPathUtf8) return -1;
value->error.clear();
value->renderBatches.clear();
value->animationState.reset();
value->animationStateData.reset();
value->skeleton.reset();
value->skeletonData.reset();
value->atlas.reset();
value->atlas = std::make_unique<spine::Atlas>(request->atlasPathUtf8, &value->textureLoader, true);
const float scale = request->scale > 0 ? request->scale : 1.0F;
if (lowercaseExtension(request->skeletonPathUtf8) == ".json") {
spine::SkeletonJson loader(value->atlas.get());
loader.setScale(scale);
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
if (!value->skeletonData) value->error = loader.getError().buffer();
} else {
spine::SkeletonBinary loader(value->atlas.get());
loader.setScale(scale);
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
if (!value->skeletonData) value->error = loader.getError().buffer();
}
if (!value->skeletonData) {
if (value->error.empty()) value->error = "Spine Runtime could not load skeleton data.";
return -2;
}
collectMetadata(*value);
value->skeleton = std::make_unique<spine::Skeleton>(value->skeletonData.get());
value->animationStateData = std::make_unique<spine::AnimationStateData>(value->skeletonData.get());
value->animationState = std::make_unique<spine::AnimationState>(value->animationStateData.get());
if (!value->animationNames.empty())
value->animationState->setAnimation(0, value->animationNames.front().c_str(), true);
value->animationState->apply(*value->skeleton);
value->skeleton->updateWorldTransform();
collectDrawCommands(*value);
return 0;
}
int32_t dtsRuntimeGetSkeletonInfo(DtsRuntimeHandle handle, DtsSkeletonInfo* info) {
auto* value = context(handle);
if (!value || !value->skeletonData || !info || info->structSize < sizeof(DtsSkeletonInfo)) return -1;
auto* data = value->skeletonData.get();
info->boneCount = static_cast<uint32_t>(data->getBones().size());
info->slotCount = static_cast<uint32_t>(data->getSlots().size());
info->skinCount = static_cast<uint32_t>(value->skinNames.size());
info->animationCount = static_cast<uint32_t>(value->animationNames.size());
info->eventCount = static_cast<uint32_t>(data->getEvents().size());
info->constraintCount = static_cast<uint32_t>(data->getIkConstraints().size()
+ data->getTransformConstraints().size() + data->getPathConstraints().size());
return 0;
}
const char* dtsRuntimeGetAnimationName(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->animationNames.size() ? value->animationNames[index].c_str() : nullptr;
}
float dtsRuntimeGetAnimationDuration(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->animationDurations.size() ? value->animationDurations[index] : 0.0F;
}
const char* dtsRuntimeGetSkinName(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->skinNames.size() ? value->skinNames[index].c_str() : nullptr;
}
const char* dtsRuntimeGetLastError(DtsRuntimeHandle handle) {
auto* value = context(handle);
return value ? value->error.c_str() : "Invalid runtime handle.";
}
int32_t dtsRuntimeSetAnimation(DtsRuntimeHandle handle, const char* name, int32_t loop) {
auto* value = context(handle);
if (!value || !value->animationState || !name) return -1;
if (!value->skeletonData->findAnimation(name)) {
value->error = "Animation was not found.";
return -2;
}
value->skeleton->setToSetupPose();
value->animationState->setAnimation(0, name, loop != 0);
return 0;
}
int32_t dtsRuntimeUpdate(DtsRuntimeHandle handle, float seconds) {
auto* value = context(handle);
if (!value || !value->animationState || !value->skeleton) return -1;
value->animationState->update(std::max(0.0F, seconds));
value->animationState->apply(*value->skeleton);
value->skeleton->updateWorldTransform();
collectDrawCommands(*value);
return 0;
}
uint32_t dtsRuntimeGetDrawCommandCount(DtsRuntimeHandle handle) {
auto* value = context(handle);
return value ? static_cast<uint32_t>(value->renderBatches.size()) : 0;
}
int32_t dtsRuntimeGetDrawCommand(DtsRuntimeHandle handle, uint32_t index, DtsDrawCommand* command) {
auto* value = context(handle);
if (!value || index >= value->renderBatches.size() || !command
|| command->structSize < sizeof(DtsDrawCommand)) return -1;
const auto& batch = value->renderBatches[index];
command->vertices = batch.vertices.data();
command->vertexCount = static_cast<uint32_t>(batch.vertices.size());
command->indices = batch.indices.data();
command->indexCount = static_cast<uint32_t>(batch.indices.size());
command->texturePathUtf8 = batch.texturePath.c_str();
command->blendMode = batch.blendMode;
return 0;
}
} // extern "C"
@@ -0,0 +1,2 @@
#define DTS_SPINE_LINE "3.8"
#include "../common/LegacyRuntimeAdapter.inl"
@@ -0,0 +1,2 @@
#define DTS_SPINE_LINE "4.0"
#include "../common/LegacyRuntimeAdapter.inl"
@@ -0,0 +1,3 @@
#define DTS_SPINE_LINE "4.1"
#define DTS_SPINE_41
#include "../common/LegacyRuntimeAdapter.inl"
@@ -0,0 +1,213 @@
#include "RuntimeApi.h"
#include <spine/Animation.h>
#include <spine/AnimationState.h>
#include <spine/AnimationStateData.h>
#include <spine/Atlas.h>
#include <spine/Extension.h>
#include <spine/Skeleton.h>
#include <spine/SkeletonBinary.h>
#include <spine/SkeletonData.h>
#include <spine/SkeletonJson.h>
#include <spine/SkeletonRenderer.h>
#include <spine/Skin.h>
#include <spine/TextureLoader.h>
#include <algorithm>
#include <cctype>
#include <cstddef>
#include <memory>
#include <string>
#include <vector>
namespace {
struct TextureRecord { std::string path; };
class MetadataTextureLoader final : public spine::TextureLoader {
public:
void load(spine::AtlasPage& page, const spine::String& path) override {
page.texture = new TextureRecord{path.buffer()};
}
void unload(void* texture) override { delete static_cast<TextureRecord*>(texture); }
};
struct RenderBatch {
std::vector<DtsRenderVertex> vertices;
std::vector<std::uint16_t> indices;
std::string texturePath;
std::uint32_t blendMode = 0;
};
struct RuntimeContext {
MetadataTextureLoader textureLoader;
std::unique_ptr<spine::Atlas> atlas;
std::unique_ptr<spine::SkeletonData> skeletonData;
std::unique_ptr<spine::Skeleton> skeleton;
std::unique_ptr<spine::AnimationStateData> animationStateData;
std::unique_ptr<spine::AnimationState> animationState;
std::unique_ptr<spine::SkeletonRenderer> renderer;
std::vector<std::string> animationNames;
std::vector<float> animationDurations;
std::vector<std::string> skinNames;
std::vector<RenderBatch> renderBatches;
std::string error;
};
RuntimeContext* context(DtsRuntimeHandle handle) { return static_cast<RuntimeContext*>(handle); }
std::string lowercaseExtension(const std::string& path) {
const auto dot = path.find_last_of('.');
auto extension = dot == std::string::npos ? std::string{} : path.substr(dot);
std::transform(extension.begin(), extension.end(), extension.begin(),
[](unsigned char value) { return static_cast<char>(std::tolower(value)); });
return extension;
}
void collectMetadata(RuntimeContext& value) {
value.animationNames.clear();
value.animationDurations.clear();
auto& animations = value.skeletonData->getAnimations();
for (std::size_t index = 0; index < animations.size(); ++index) {
value.animationNames.emplace_back(animations[index]->getName().buffer());
value.animationDurations.push_back(animations[index]->getDuration());
}
value.skinNames.clear();
auto& skins = value.skeletonData->getSkins();
for (std::size_t index = 0; index < skins.size(); ++index)
value.skinNames.emplace_back(skins[index]->getName().buffer());
}
void collectDrawCommands(RuntimeContext& value) {
value.renderBatches.clear();
for (auto* command = value.renderer->render(*value.skeleton); command; command = command->next) {
RenderBatch batch;
batch.blendMode = static_cast<std::uint32_t>(command->blendMode);
if (const auto* texture = static_cast<TextureRecord*>(command->texture))
batch.texturePath = texture->path;
for (int index = 0; index < command->numVertices; ++index)
batch.vertices.push_back({command->positions[index * 2], -command->positions[index * 2 + 1],
command->uvs[index * 2], command->uvs[index * 2 + 1], command->colors[index]});
batch.indices.assign(command->indices, command->indices + command->numIndices);
value.renderBatches.push_back(std::move(batch));
}
}
} // namespace
namespace spine {
SpineExtension* getDefaultExtension() { return new DefaultSpineExtension(); }
} // namespace spine
extern "C" {
uint32_t dtsRuntimeGetApiVersion() { return DTS_RUNTIME_API_VERSION; }
int32_t dtsRuntimeGetInfo(DtsRuntimeInfo* info) {
if (!info || info->structSize < sizeof(DtsRuntimeInfo)) return -1;
info->apiVersion = DTS_RUNTIME_API_VERSION;
info->spineVersionLine = "4.2";
info->adapterBuild = "preview-1";
info->capabilities = DTS_RUNTIME_CAP_SKELETON_METADATA | DTS_RUNTIME_CAP_DRAW_COMMANDS;
return 0;
}
DtsRuntimeHandle dtsRuntimeCreate() { return new RuntimeContext; }
void dtsRuntimeDestroy(DtsRuntimeHandle handle) { delete context(handle); }
int32_t dtsRuntimeLoad(DtsRuntimeHandle handle, const DtsLoadRequest* request) {
auto* value = context(handle);
if (!value || !request || request->structSize < sizeof(DtsLoadRequest)
|| !request->skeletonPathUtf8 || !request->atlasPathUtf8) return -1;
value->error.clear();
value->renderBatches.clear();
value->renderer.reset();
value->animationState.reset();
value->animationStateData.reset();
value->skeleton.reset();
value->skeletonData.reset();
value->atlas.reset();
value->atlas = std::make_unique<spine::Atlas>(request->atlasPathUtf8, &value->textureLoader, true);
const float scale = request->scale > 0 ? request->scale : 1.0F;
if (lowercaseExtension(request->skeletonPathUtf8) == ".json") {
spine::SkeletonJson loader(value->atlas.get());
loader.setScale(scale);
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
if (!value->skeletonData) value->error = loader.getError().buffer();
} else {
spine::SkeletonBinary loader(value->atlas.get());
loader.setScale(scale);
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
if (!value->skeletonData) value->error = loader.getError().buffer();
}
if (!value->skeletonData) {
if (value->error.empty()) value->error = "Spine Runtime could not load skeleton data.";
return -2;
}
collectMetadata(*value);
value->skeleton = std::make_unique<spine::Skeleton>(value->skeletonData.get());
value->animationStateData = std::make_unique<spine::AnimationStateData>(value->skeletonData.get());
value->animationState = std::make_unique<spine::AnimationState>(value->animationStateData.get());
value->renderer = std::make_unique<spine::SkeletonRenderer>();
if (!value->animationNames.empty())
value->animationState->setAnimation(0, value->animationNames.front().c_str(), true);
value->animationState->apply(*value->skeleton);
value->skeleton->updateWorldTransform(spine::Physics_Update);
collectDrawCommands(*value);
return 0;
}
int32_t dtsRuntimeGetSkeletonInfo(DtsRuntimeHandle handle, DtsSkeletonInfo* info) {
auto* value = context(handle);
if (!value || !value->skeletonData || !info || info->structSize < sizeof(DtsSkeletonInfo)) return -1;
auto* data = value->skeletonData.get();
info->boneCount = static_cast<uint32_t>(data->getBones().size());
info->slotCount = static_cast<uint32_t>(data->getSlots().size());
info->skinCount = static_cast<uint32_t>(value->skinNames.size());
info->animationCount = static_cast<uint32_t>(value->animationNames.size());
info->eventCount = static_cast<uint32_t>(data->getEvents().size());
info->constraintCount = static_cast<uint32_t>(data->getIkConstraints().size()
+ data->getTransformConstraints().size() + data->getPathConstraints().size()
+ data->getPhysicsConstraints().size());
return 0;
}
const char* dtsRuntimeGetAnimationName(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->animationNames.size() ? value->animationNames[index].c_str() : nullptr;
}
float dtsRuntimeGetAnimationDuration(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->animationDurations.size() ? value->animationDurations[index] : 0.0F;
}
const char* dtsRuntimeGetSkinName(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->skinNames.size() ? value->skinNames[index].c_str() : nullptr;
}
const char* dtsRuntimeGetLastError(DtsRuntimeHandle handle) {
auto* value = context(handle);
return value ? value->error.c_str() : "Invalid runtime handle.";
}
int32_t dtsRuntimeSetAnimation(DtsRuntimeHandle handle, const char* name, int32_t loop) {
auto* value = context(handle);
if (!value || !value->animationState || !name) return -1;
if (!value->skeletonData->findAnimation(name)) {
value->error = "Animation was not found.";
return -2;
}
value->skeleton->setToSetupPose();
value->animationState->setAnimation(0, name, loop != 0);
return 0;
}
int32_t dtsRuntimeUpdate(DtsRuntimeHandle handle, float seconds) {
auto* value = context(handle);
if (!value || !value->animationState || !value->skeleton || !value->renderer) return -1;
value->animationState->update(std::max(0.0F, seconds));
value->animationState->apply(*value->skeleton);
value->skeleton->updateWorldTransform(spine::Physics_Update);
collectDrawCommands(*value);
return 0;
}
uint32_t dtsRuntimeGetDrawCommandCount(DtsRuntimeHandle handle) {
auto* value = context(handle);
return value ? static_cast<uint32_t>(value->renderBatches.size()) : 0;
}
int32_t dtsRuntimeGetDrawCommand(DtsRuntimeHandle handle, uint32_t index, DtsDrawCommand* command) {
auto* value = context(handle);
if (!value || index >= value->renderBatches.size() || !command
|| command->structSize < sizeof(DtsDrawCommand)) return -1;
const auto& batch = value->renderBatches[index];
command->vertices = batch.vertices.data();
command->vertexCount = static_cast<uint32_t>(batch.vertices.size());
command->indices = batch.indices.data();
command->indexCount = static_cast<uint32_t>(batch.indices.size());
command->texturePathUtf8 = batch.texturePath.c_str();
command->blendMode = batch.blendMode;
return 0;
}
} // extern "C"
@@ -0,0 +1,272 @@
#include "RuntimeApi.h"
#include <spine/Animation.h>
#include <spine/AnimationState.h>
#include <spine/AnimationStateData.h>
#include <spine/Atlas.h>
#include <spine/Extension.h>
#include <spine/Skeleton.h>
#include <spine/SkeletonBinary.h>
#include <spine/SkeletonData.h>
#include <spine/SkeletonJson.h>
#include <spine/SkeletonRenderer.h>
#include <spine/Skin.h>
#include <spine/TextureLoader.h>
#include <algorithm>
#include <cctype>
#include <cstddef>
#include <memory>
#include <string>
#include <vector>
namespace {
struct TextureRecord {
std::string path;
};
class MetadataTextureLoader final : public spine::TextureLoader {
public:
void load(spine::AtlasPage& page, const spine::String& path) override {
page.texture = new TextureRecord{path.buffer()};
}
void unload(void* texture) override { delete static_cast<TextureRecord*>(texture); }
};
struct RenderBatch {
std::vector<DtsRenderVertex> vertices;
std::vector<std::uint16_t> indices;
std::string texturePath;
std::uint32_t blendMode = 0;
};
struct RuntimeContext {
MetadataTextureLoader textureLoader;
std::unique_ptr<spine::Atlas> atlas;
std::unique_ptr<spine::SkeletonData> skeletonData;
std::unique_ptr<spine::Skeleton> skeleton;
std::unique_ptr<spine::AnimationStateData> animationStateData;
std::unique_ptr<spine::AnimationState> animationState;
std::unique_ptr<spine::SkeletonRenderer> renderer;
std::vector<std::string> animationNames;
std::vector<float> animationDurations;
std::vector<std::string> skinNames;
std::vector<RenderBatch> renderBatches;
std::string error;
};
RuntimeContext* context(DtsRuntimeHandle handle) {
return static_cast<RuntimeContext*>(handle);
}
std::string lowercaseExtension(const std::string& path) {
const auto dot = path.find_last_of('.');
auto extension = dot == std::string::npos ? std::string{} : path.substr(dot);
std::transform(extension.begin(), extension.end(), extension.begin(),
[](unsigned char value) { return static_cast<char>(std::tolower(value)); });
return extension;
}
void collectMetadata(RuntimeContext& value) {
value.animationNames.clear();
value.animationDurations.clear();
auto& animations = value.skeletonData->getAnimations();
for (std::size_t index = 0; index < animations.size(); ++index) {
auto* animation = animations[index];
value.animationNames.emplace_back(animation->getName().buffer());
value.animationDurations.push_back(animation->getDuration());
}
value.skinNames.clear();
auto& skins = value.skeletonData->getSkins();
for (std::size_t index = 0; index < skins.size(); ++index) {
auto* skin = skins[index];
value.skinNames.emplace_back(skin->getName().buffer());
}
}
void collectDrawCommands(RuntimeContext& value) {
value.renderBatches.clear();
for (auto* command = value.renderer->render(*value.skeleton); command;
command = command->next) {
RenderBatch batch;
batch.blendMode = static_cast<std::uint32_t>(command->blendMode);
if (const auto* texture = static_cast<TextureRecord*>(command->texture))
batch.texturePath = texture->path;
batch.vertices.reserve(static_cast<std::size_t>(command->numVertices));
for (int index = 0; index < command->numVertices; ++index) {
batch.vertices.push_back({
command->positions[index * 2], command->positions[index * 2 + 1],
command->uvs[index * 2], command->uvs[index * 2 + 1],
command->colors[index]});
}
batch.indices.assign(command->indices, command->indices + command->numIndices);
value.renderBatches.push_back(std::move(batch));
}
}
} // namespace
namespace spine {
SpineExtension* getDefaultExtension() {
return new DefaultSpineExtension();
}
} // namespace spine
extern "C" {
uint32_t dtsRuntimeGetApiVersion() {
return DTS_RUNTIME_API_VERSION;
}
int32_t dtsRuntimeGetInfo(DtsRuntimeInfo* info) {
if (!info || info->structSize < sizeof(DtsRuntimeInfo)) {
return -1;
}
info->apiVersion = DTS_RUNTIME_API_VERSION;
info->spineVersionLine = "4.3";
info->adapterBuild = "prototype-2";
info->capabilities = DTS_RUNTIME_CAP_SKELETON_METADATA | DTS_RUNTIME_CAP_DRAW_COMMANDS;
return 0;
}
DtsRuntimeHandle dtsRuntimeCreate() {
return new RuntimeContext;
}
void dtsRuntimeDestroy(DtsRuntimeHandle handle) {
delete context(handle);
}
int32_t dtsRuntimeLoad(DtsRuntimeHandle handle, const DtsLoadRequest* request) {
auto* value = context(handle);
if (!value || !request || request->structSize < sizeof(DtsLoadRequest)
|| !request->skeletonPathUtf8 || !request->atlasPathUtf8) {
return -1;
}
value->error.clear();
value->renderBatches.clear();
value->renderer.reset();
value->animationState.reset();
value->animationStateData.reset();
value->skeleton.reset();
value->skeletonData.reset();
value->atlas.reset();
value->atlas = std::make_unique<spine::Atlas>(
request->atlasPathUtf8, &value->textureLoader, true);
const float scale = request->scale > 0 ? request->scale : 1.0F;
if (lowercaseExtension(request->skeletonPathUtf8) == ".json") {
spine::SkeletonJson loader(*value->atlas);
loader.setScale(scale);
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
if (!value->skeletonData) value->error = loader.getError().buffer();
} else {
spine::SkeletonBinary loader(*value->atlas);
loader.setScale(scale);
value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8));
if (!value->skeletonData) value->error = loader.getError().buffer();
}
if (!value->skeletonData) {
if (value->error.empty()) value->error = "Spine Runtime could not load skeleton data.";
return -2;
}
collectMetadata(*value);
value->skeleton = std::make_unique<spine::Skeleton>(*value->skeletonData);
value->animationStateData = std::make_unique<spine::AnimationStateData>(
*value->skeletonData);
value->animationState = std::make_unique<spine::AnimationState>(
*value->animationStateData);
value->renderer = std::make_unique<spine::SkeletonRenderer>();
if (!value->animationNames.empty())
value->animationState->setAnimation(0, value->animationNames.front().c_str(), true);
value->animationState->apply(*value->skeleton);
value->skeleton->updateWorldTransform(spine::Physics_Update);
collectDrawCommands(*value);
return 0;
}
int32_t dtsRuntimeGetSkeletonInfo(DtsRuntimeHandle handle, DtsSkeletonInfo* info) {
auto* value = context(handle);
if (!value || !value->skeletonData || !info || info->structSize < sizeof(DtsSkeletonInfo)) {
return -1;
}
info->boneCount = static_cast<uint32_t>(value->skeletonData->getBones().size());
info->slotCount = static_cast<uint32_t>(value->skeletonData->getSlots().size());
info->skinCount = static_cast<uint32_t>(value->skinNames.size());
info->animationCount = static_cast<uint32_t>(value->animationNames.size());
info->eventCount = static_cast<uint32_t>(value->skeletonData->getEvents().size());
info->constraintCount = static_cast<uint32_t>(value->skeletonData->getConstraints().size());
return 0;
}
const char* dtsRuntimeGetAnimationName(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->animationNames.size()
? value->animationNames[index].c_str() : nullptr;
}
float dtsRuntimeGetAnimationDuration(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->animationDurations.size()
? value->animationDurations[index] : 0.0F;
}
const char* dtsRuntimeGetSkinName(DtsRuntimeHandle handle, uint32_t index) {
auto* value = context(handle);
return value && index < value->skinNames.size() ? value->skinNames[index].c_str() : nullptr;
}
const char* dtsRuntimeGetLastError(DtsRuntimeHandle handle) {
auto* value = context(handle);
return value ? value->error.c_str() : "Invalid runtime handle.";
}
int32_t dtsRuntimeSetAnimation(
DtsRuntimeHandle handle, const char* animationNameUtf8, int32_t loop) {
auto* value = context(handle);
if (!value || !value->animationState || !animationNameUtf8) return -1;
if (!value->skeletonData->findAnimation(animationNameUtf8)) {
value->error = "Animation was not found.";
return -2;
}
value->skeleton->setupPose();
value->animationState->setAnimation(0, animationNameUtf8, loop != 0);
return 0;
}
int32_t dtsRuntimeUpdate(DtsRuntimeHandle handle, float deltaSeconds) {
auto* value = context(handle);
if (!value || !value->animationState || !value->skeleton || !value->renderer) return -1;
value->animationState->update(std::max(0.0F, deltaSeconds));
value->animationState->apply(*value->skeleton);
value->skeleton->updateWorldTransform(spine::Physics_Update);
collectDrawCommands(*value);
return 0;
}
uint32_t dtsRuntimeGetDrawCommandCount(DtsRuntimeHandle handle) {
auto* value = context(handle);
return value ? static_cast<uint32_t>(value->renderBatches.size()) : 0;
}
int32_t dtsRuntimeGetDrawCommand(
DtsRuntimeHandle handle, uint32_t index, DtsDrawCommand* command) {
auto* value = context(handle);
if (!value || index >= value->renderBatches.size() || !command
|| command->structSize < sizeof(DtsDrawCommand)) return -1;
const auto& batch = value->renderBatches[index];
command->vertices = batch.vertices.data();
command->vertexCount = static_cast<uint32_t>(batch.vertices.size());
command->indices = batch.indices.data();
command->indexCount = static_cast<uint32_t>(batch.indices.size());
command->texturePathUtf8 = batch.texturePath.c_str();
command->blendMode = batch.blendMode;
return 0;
}
} // extern "C"
+90
View File
@@ -0,0 +1,90 @@
#include "editor/SpineEditorLocator.hpp"
#include "editor/SpineProbe.hpp"
#include "platform/ProcessRunner.hpp"
#include "scan/SpineDataVersionDetector.hpp"
#include <chrono>
#include <filesystem>
#include <iostream>
#include <string>
namespace {
void printProbe(const dts::EditorProbeResult& result) {
std::cout << "path: " << result.executable.string() << '\n';
std::cout << "status: " << dts::toString(result.status) << '\n';
if (result.launcherVersion) {
std::cout << "launcher: " << result.launcherVersion->toString() << '\n';
}
if (result.editorVersion) {
std::cout << "editor: " << result.editorVersion->toString() << '\n';
}
if (!result.edition.empty()) {
std::cout << "edition: " << result.edition << '\n';
}
std::cout << "message: " << result.message << '\n';
}
void printUsage() {
std::cout << "DateToSpine technical prototype\n"
<< "Usage:\n"
<< " datetospine-cli detect\n"
<< " datetospine-cli probe <Spine.app|executable> [timeout-ms]\n"
<< " datetospine-cli inspect-data <skeleton.json|skeleton.skel>\n";
}
} // namespace
int main(int argc, char** argv) {
if (argc < 2) {
printUsage();
return 2;
}
const std::string command = argv[1];
dts::SpineEditorLocator locator;
if (command == "detect") {
const auto candidates = locator.discover();
for (const auto& candidate : candidates) {
std::cout << candidate.string() << '\n';
}
return candidates.empty() ? 1 : 0;
}
if (command == "probe" && argc >= 3) {
const auto executable = locator.normalize(std::filesystem::path(argv[2]));
if (!executable) {
std::cerr << "The supplied path does not contain a Spine executable.\n";
return 1;
}
auto timeout = std::chrono::milliseconds(10000);
if (argc >= 4) {
try {
timeout = std::chrono::milliseconds(std::stoll(argv[3]));
} catch (...) {
std::cerr << "Invalid timeout.\n";
return 2;
}
}
auto runner = dts::createDefaultProcessRunner();
dts::SpineProbe probe(*runner);
const auto result = probe.inspect(*executable, timeout);
printProbe(result);
return result.status == dts::EditorProbeStatus::Available ? 0 : 1;
}
if (command == "inspect-data" && argc >= 3) {
const auto result = dts::SpineDataVersionDetector{}.inspect(argv[2]);
std::cout << "format: " << dts::toString(result.format) << '\n';
std::cout << "status: " << dts::toString(result.status) << '\n';
if (result.version) {
std::cout << "version: " << result.version->toString() << '\n';
}
std::cout << "message: " << result.message << '\n';
return result.status == dts::VersionDetectionStatus::Detected ? 0 : 1;
}
printUsage();
return 2;
}
+35
View File
@@ -0,0 +1,35 @@
#include "ui/MainWindow.hpp"
#include <QApplication>
#include <QCoreApplication>
#include <QPixmap>
#include <QTimer>
int main(int argc, char** argv) {
QApplication application(argc, argv);
QCoreApplication::setApplicationName("DateToSpine");
QCoreApplication::setOrganizationName("DateToSpine");
dts::MainWindow window;
window.show();
const auto arguments = application.arguments();
for (int index = 1; index < arguments.size(); ++index) {
if (arguments[index] == "--screenshot") {
++index;
} else if (!arguments[index].startsWith('-')) {
window.openResourcePath(arguments[index]);
}
}
const int screenshotOption = arguments.indexOf("--screenshot");
if (screenshotOption >= 0 && screenshotOption + 1 < arguments.size()) {
const auto screenshotPath = arguments[screenshotOption + 1];
QTimer::singleShot(250, &application, [&application, &window, screenshotPath] {
window.grab().save(screenshotPath);
application.quit();
});
}
if (application.arguments().contains("--smoke-test")) {
QTimer::singleShot(100, &application, &QCoreApplication::quit);
}
return application.exec();
}
+173
View File
@@ -0,0 +1,173 @@
#include "atlas/AtlasParser.hpp"
#include <algorithm>
#include <charconv>
#include <cctype>
#include <fstream>
#include <sstream>
namespace dts {
namespace {
std::string trim(std::string value) {
const auto isSpace = [](unsigned char character) { return std::isspace(character) != 0; };
value.erase(value.begin(), std::find_if_not(value.begin(), value.end(), isSpace));
value.erase(std::find_if_not(value.rbegin(), value.rend(), isSpace).base(), value.end());
return value;
}
std::vector<std::string> splitValues(const std::string& value) {
std::vector<std::string> values;
std::stringstream stream(value);
std::string item;
while (std::getline(stream, item, ',')) {
values.push_back(trim(item));
}
return values;
}
std::optional<std::vector<int>> parseIntegers(const std::string& value) {
std::vector<int> integers;
for (const auto& item : splitValues(value)) {
int number = 0;
const auto result = std::from_chars(item.data(), item.data() + item.size(), number);
if (result.ec != std::errc{} || result.ptr != item.data() + item.size()) {
return std::nullopt;
}
integers.push_back(number);
}
return integers;
}
bool isPageKey(const std::string& key) {
return key == "size" || key == "format" || key == "filter" || key == "repeat"
|| key == "pma" || key == "scale";
}
bool parseBoolean(const std::string& value) {
return value == "true" || value == "yes" || value == "1";
}
void applyPageProperty(AtlasPage& page, const std::string& key, const std::string& value) {
if (key == "size") {
page.size = parseIntegers(value).value_or(std::vector<int>{});
} else if (key == "format") {
page.format = value;
} else if (key == "filter") {
page.filter = value;
} else if (key == "repeat") {
page.repeat = value;
} else if (key == "pma") {
page.premultipliedAlpha = parseBoolean(value);
} else if (key == "scale") {
try {
page.scale = std::stod(value);
} catch (...) {
page.scale.reset();
}
}
}
void applyRegionProperty(AtlasRegion& region, const std::string& key, const std::string& value) {
if (key == "rotate") {
region.rotated = parseBoolean(value) || value == "90" || value == "270";
} else if (key == "bounds") {
region.bounds = parseIntegers(value).value_or(std::vector<int>{});
} else if (key == "xy") {
region.position = parseIntegers(value).value_or(std::vector<int>{});
} else if (key == "size") {
region.size = parseIntegers(value).value_or(std::vector<int>{});
} else if (key == "orig") {
region.originalSize = parseIntegers(value).value_or(std::vector<int>{});
} else if (key == "offset" || key == "offsets") {
region.offsets = parseIntegers(value).value_or(std::vector<int>{});
} else if (key == "split") {
region.split = parseIntegers(value).value_or(std::vector<int>{});
} else if (key == "pad") {
region.pad = parseIntegers(value).value_or(std::vector<int>{});
} else if (key == "index") {
const auto values = parseIntegers(value);
if (values && !values->empty()) {
region.index = values->front();
}
}
}
} // namespace
AtlasParseResult AtlasParser::parseFile(const std::filesystem::path& path) const {
std::ifstream input(path, std::ios::binary);
if (!input) {
return {std::nullopt, "Atlas file could not be opened.", 0};
}
std::ostringstream contents;
contents << input.rdbuf();
return parseText(contents.str());
}
AtlasParseResult AtlasParser::parseText(const std::string& text) const {
AtlasDocument document;
AtlasPage* page = nullptr;
AtlasRegion* region = nullptr;
bool expectPage = true;
std::istringstream input(text);
std::string rawLine;
std::size_t lineNumber = 0;
while (std::getline(input, rawLine)) {
++lineNumber;
if (!rawLine.empty() && rawLine.back() == '\r') {
rawLine.pop_back();
}
const auto line = trim(rawLine);
if (line.empty()) {
expectPage = true;
page = nullptr;
region = nullptr;
continue;
}
const auto colon = line.find(':');
if (colon == std::string::npos) {
if (expectPage || page == nullptr) {
AtlasPage nextPage;
nextPage.imagePath = std::filesystem::path(line);
document.pages.push_back(std::move(nextPage));
page = &document.pages.back();
region = nullptr;
expectPage = false;
} else {
AtlasRegion nextRegion;
nextRegion.name = line;
page->regions.push_back(std::move(nextRegion));
region = &page->regions.back();
}
continue;
}
if (!page) {
return {std::nullopt, "Atlas property appears before a page name.", lineNumber};
}
const auto key = trim(line.substr(0, colon));
const auto value = trim(line.substr(colon + 1));
if (!region && isPageKey(key)) {
applyPageProperty(*page, key, value);
} else if (region) {
applyRegionProperty(*region, key, value);
} else {
document.warnings.push_back("Unknown page property at line " + std::to_string(lineNumber));
}
}
if (document.pages.empty()) {
return {std::nullopt, "Atlas does not contain any pages.", 0};
}
for (const auto& atlasPage : document.pages) {
if (atlasPage.imagePath.empty()) {
return {std::nullopt, "Atlas contains an empty page name.", 0};
}
}
return {std::move(document), {}, 0};
}
} // namespace dts
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <filesystem>
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace dts {
struct AtlasRegion {
std::string name;
bool rotated = false;
std::vector<int> bounds;
std::vector<int> position;
std::vector<int> size;
std::vector<int> originalSize;
std::vector<int> offsets;
std::vector<int> split;
std::vector<int> pad;
std::optional<int> index;
};
struct AtlasPage {
std::filesystem::path imagePath;
std::vector<int> size;
std::string format;
std::string filter;
std::string repeat;
std::optional<bool> premultipliedAlpha;
std::optional<double> scale;
std::vector<AtlasRegion> regions;
};
struct AtlasDocument {
std::vector<AtlasPage> pages;
std::vector<std::string> warnings;
};
struct AtlasParseResult {
std::optional<AtlasDocument> document;
std::string error;
std::size_t errorLine = 0;
};
class AtlasParser {
public:
[[nodiscard]] AtlasParseResult parseFile(const std::filesystem::path& path) const;
[[nodiscard]] AtlasParseResult parseText(const std::string& text) const;
};
} // namespace dts
@@ -0,0 +1,218 @@
#include "conversion/ConversionJob.hpp"
#include "atlas/AtlasParser.hpp"
#include "domain/SpineVersion.hpp"
#include "editor/SpineVersionAvailability.hpp"
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QProcess>
#include <QRegularExpression>
#include <QTemporaryDir>
#include <cmath>
#include <filesystem>
namespace dts {
namespace {
std::filesystem::path localPath(const QString& value) {
#if defined(_WIN32)
return std::filesystem::path(value.toStdWString());
#else
return std::filesystem::path(value.toStdString());
#endif
}
QString uniqueDirectory(const QString& parent, const QString& name) {
QDir directory(parent);
QString candidate = directory.filePath(name);
for (int suffix = 2; QFileInfo::exists(candidate); ++suffix)
candidate = directory.filePath(QStringLiteral("%1-%2").arg(name).arg(suffix));
return candidate;
}
} // namespace
ConversionJob::ConversionJob(ConversionRequest request, QObject* parent)
: QObject(parent), request_(std::move(request)) {}
ConversionJob::~ConversionJob() = default;
void ConversionJob::start() {
QString error;
if (!prepare(error)) {
fail(error);
return;
}
startUnpack();
}
bool ConversionJob::prepare(QString& error) {
const auto version = SpineVersion::parse(request_.editorVersion.toStdString());
if (!version || !SpineVersionAvailability{}.isCached(*version)) {
error = tr("本地未缓存 Spine %1。为避免触发下载,转换已停止。")
.arg(request_.editorVersion);
return false;
}
if (!QFileInfo::exists(request_.editorPath)) {
error = tr("找不到已指定的 Spine Editor。请重新指定程序路径。");
return false;
}
const auto atlas = AtlasParser{}.parseFile(localPath(request_.atlasPath));
if (!atlas.document || atlas.document->pages.empty()) {
error = tr("Atlas 无法识别,不能确定官方导入比例。");
return false;
}
double scale = atlas.document->pages.front().scale.value_or(1.0);
if (!std::isfinite(scale) || scale <= 0.0) {
error = tr("Atlas 中的 scale 无效。");
return false;
}
for (const auto& page : atlas.document->pages) {
const double pageScale = page.scale.value_or(1.0);
if (!std::isfinite(pageScale) || std::abs(pageScale - scale) > 0.000001) {
error = tr("Atlas 的纹理页使用了不同 scale,无法生成尺寸一致的工程。");
return false;
}
}
importScale_ = QString::number(scale, 'g', 15);
QDir parent(request_.outputParent);
if (!parent.exists() && !parent.mkpath(QStringLiteral("."))) {
error = tr("无法创建输出目录。");
return false;
}
finalDirectory_ = uniqueDirectory(request_.outputParent, request_.assetName);
staging_ = std::make_unique<QTemporaryDir>(
parent.filePath(QStringLiteral(".datetospine-XXXXXX")));
if (!staging_->isValid()) {
error = tr("无法创建临时转换目录。");
return false;
}
QDir staging(staging_->path());
if (!staging.mkpath(QStringLiteral("images"))) {
error = tr("无法准备图片目录。");
return false;
}
projectPath_ = staging.filePath(request_.assetName + QStringLiteral(".spine"));
// The official unpacker reads `pma` from the Atlas. When conversion to
// straight alpha is requested, pass the source Atlas unchanged so Spine
// performs the conversion itself. Otherwise a temporary metadata copy
// disables that conversion while leaving all geometry fields intact.
unpackAtlasPath_ = request_.unpremultiplyAlpha
? request_.atlasPath
: staging.filePath(QStringLiteral("unpack.atlas"));
return writeUnpackAtlas(error);
}
bool ConversionJob::writeUnpackAtlas(QString& error) {
if (request_.unpremultiplyAlpha) return true;
QFile source(request_.atlasPath);
if (!source.open(QIODevice::ReadOnly)) {
error = tr("无法读取 Atlas 文件。");
return false;
}
auto contents = QString::fromUtf8(source.readAll());
source.close();
// Preserve packed pixel values when the user explicitly leaves PMA enabled.
// Pixel decoding, cropping and Alpha handling remain entirely inside Spine.
contents.replace(QRegularExpression(
QStringLiteral("(?m)^(\\s*pma\\s*:\\s*)true(\\s*)$")), QStringLiteral("\\1false\\2"));
QFile temporary(unpackAtlasPath_);
const auto encoded = contents.toUtf8();
if (!temporary.open(QIODevice::WriteOnly | QIODevice::Truncate)
|| temporary.write(encoded) != encoded.size()) {
error = tr("无法准备离线图集解包配置。");
return false;
}
return true;
}
void ConversionJob::startUnpack() {
stage_ = Stage::Unpacking;
process_ = new QProcess(this);
process_->setProcessChannelMode(QProcess::MergedChannels);
connect(process_, &QProcess::finished, this, &ConversionJob::processFinished);
connect(process_, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) {
if (error == QProcess::FailedToStart && stage_ != Stage::Done)
fail(tr("无法启动本机 Spine Editor。"));
});
process_->start(request_.editorPath, {
"-u", request_.editorVersion,
"-i", QFileInfo(request_.atlasPath).absolutePath(),
"-o", QDir(staging_->path()).filePath(QStringLiteral("images")),
"-c", unpackAtlasPath_});
}
void ConversionJob::startImport() {
stage_ = Stage::Importing;
process_->start(request_.editorPath, {
"-u", request_.editorVersion,
"-i", request_.skeletonPath,
// Numeric scale works with all supported Editor lines. Older Editors
// (notably 4.0) do not accept an Atlas path for this argument.
"-s", importScale_,
"-o", projectPath_,
"--import"});
}
bool ConversionJob::hasUnpackedImages() const {
QDirIterator iterator(QDir(staging_->path()).filePath(QStringLiteral("images")),
{"*.png", "*.jpg", "*.jpeg", "*.webp"}, QDir::Files,
QDirIterator::Subdirectories);
return iterator.hasNext();
}
void ConversionJob::processFinished(int exitCode) {
if (stage_ == Stage::Done) return;
const auto output = QString::fromUtf8(process_->readAll());
if (exitCode != 0) {
fail(stage_ == Stage::Unpacking
? tr("官方图集解包失败:\n%1").arg(output.right(1600))
: tr("Spine 工程导入失败:\n%1").arg(output.right(1600)));
return;
}
if (stage_ == Stage::Unpacking) {
if (!hasUnpackedImages()) {
fail(tr("官方图集解包未生成任何图片。"));
return;
}
if (unpackAtlasPath_ != request_.atlasPath) QFile::remove(unpackAtlasPath_);
startImport();
return;
}
if (stage_ == Stage::Importing) {
if (QFileInfo(projectPath_).size() <= 0) {
fail(tr("Spine Editor 未生成有效的 .spine 工程。"));
return;
}
publish();
}
}
void ConversionJob::publish() {
stage_ = Stage::Publishing;
const auto source = localPath(staging_->path());
const auto destination = localPath(finalDirectory_);
std::error_code error;
std::filesystem::rename(source, destination, error);
if (error) {
fail(tr("无法发布转换结果:%1").arg(QString::fromStdString(error.message())));
return;
}
staging_->setAutoRemove(false);
stage_ = Stage::Done;
emit finished(true, finalDirectory_, {});
}
void ConversionJob::fail(const QString& reason) {
if (stage_ == Stage::Done) return;
stage_ = Stage::Done;
if (process_) process_->kill();
emit finished(false, {}, reason);
}
} // namespace dts
@@ -0,0 +1,56 @@
#pragma once
#include <QObject>
#include <QString>
#include <memory>
class QProcess;
class QTemporaryDir;
namespace dts {
struct ConversionRequest {
QString editorPath;
QString editorVersion;
QString skeletonPath;
QString atlasPath;
QString outputParent;
QString assetName;
bool unpremultiplyAlpha = false;
};
class ConversionJob final : public QObject {
Q_OBJECT
public:
explicit ConversionJob(ConversionRequest request, QObject* parent = nullptr);
~ConversionJob() override;
void start();
signals:
void finished(bool success, const QString& outputDirectory, const QString& error);
private:
enum class Stage { Preparing, Unpacking, Importing, Publishing, Done };
bool prepare(QString& error);
bool writeUnpackAtlas(QString& error);
bool hasUnpackedImages() const;
void startUnpack();
void startImport();
void processFinished(int exitCode);
void fail(const QString& reason);
void publish();
ConversionRequest request_;
Stage stage_ = Stage::Preparing;
std::unique_ptr<QTemporaryDir> staging_;
QProcess* process_ = nullptr;
QString finalDirectory_;
QString projectPath_;
QString unpackAtlasPath_;
QString importScale_;
};
} // namespace dts
+71
View File
@@ -0,0 +1,71 @@
#include "domain/SpineVersion.hpp"
#include <charconv>
namespace dts {
namespace {
std::optional<int> parseNumber(std::string_view text) {
int value = 0;
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
if (result.ec != std::errc{} || result.ptr != text.data() + text.size()) {
return std::nullopt;
}
return value;
}
} // namespace
std::optional<SpineVersion> SpineVersion::parse(std::string_view text) {
const auto firstDot = text.find('.');
if (firstDot == std::string_view::npos) {
return std::nullopt;
}
const auto secondDot = text.find('.', firstDot + 1);
const auto majorValue = parseNumber(text.substr(0, firstDot));
const auto minorEnd = secondDot == std::string_view::npos ? text.size() : secondDot;
const auto minorValue = parseNumber(text.substr(firstDot + 1, minorEnd - firstDot - 1));
if (!majorValue || !minorValue) {
return std::nullopt;
}
SpineVersion version{*majorValue, *minorValue, 0, false};
if (secondDot != std::string_view::npos) {
auto patchText = text.substr(secondDot + 1);
const auto suffix = patchText.find_first_not_of("0123456789");
const auto numericPatch = patchText.substr(0, suffix);
if (numericPatch.empty() || (suffix != std::string_view::npos
&& patchText[suffix] != '-')) {
return std::nullopt;
}
const auto patchValue = parseNumber(numericPatch);
if (!patchValue) {
return std::nullopt;
}
version.patch = *patchValue;
version.hasPatch = true;
}
return version;
}
std::string SpineVersion::line() const {
return std::to_string(major) + "." + std::to_string(minor);
}
std::string SpineVersion::toString() const {
auto value = line();
if (hasPatch) {
value += "." + std::to_string(patch);
}
return value;
}
bool SpineVersion::isSupported() const {
if (major == 3 && minor == 8) {
return !hasPatch || patch >= 20;
}
return major == 4 && minor >= 0 && minor <= 3;
}
} // namespace dts
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <optional>
#include <string>
#include <string_view>
namespace dts {
struct SpineVersion {
int major = 0;
int minor = 0;
int patch = 0;
bool hasPatch = false;
[[nodiscard]] static std::optional<SpineVersion> parse(std::string_view text);
[[nodiscard]] std::string line() const;
[[nodiscard]] std::string toString() const;
[[nodiscard]] bool isSupported() const;
friend bool operator==(const SpineVersion&, const SpineVersion&) = default;
};
} // namespace dts
@@ -0,0 +1,79 @@
#include "editor/SpineEditorLocator.hpp"
#include <algorithm>
#include <cstdlib>
#include <system_error>
namespace dts {
namespace fs = std::filesystem;
namespace {
void addUnique(std::vector<fs::path>& paths, const fs::path& candidate) {
std::error_code error;
if (!fs::is_regular_file(candidate, error)) {
return;
}
const auto canonical = fs::weakly_canonical(candidate, error);
const auto value = error ? candidate.lexically_normal() : canonical;
if (std::find(paths.begin(), paths.end(), value) == paths.end()) {
paths.push_back(value);
}
}
} // namespace
std::vector<fs::path> SpineEditorLocator::discover() const {
std::vector<fs::path> results;
#if defined(__APPLE__)
addUnique(results, "/Applications/Spine.app/Contents/MacOS/Spine");
if (const char* userHome = std::getenv("HOME")) {
addUnique(results, fs::path(userHome) / "Applications/Spine.app/Contents/MacOS/Spine");
}
#elif defined(_WIN32)
const auto addFromRoot = [&results](const char* variable) {
if (const char* root = std::getenv(variable)) {
addUnique(results, fs::path(root) / "Spine/Spine.com");
}
};
addFromRoot("ProgramFiles");
addFromRoot("ProgramFiles(x86)");
#endif
return results;
}
std::optional<fs::path> SpineEditorLocator::normalize(const fs::path& userPath) const {
std::error_code error;
auto candidate = userPath;
#if defined(__APPLE__)
if (candidate.extension() == ".app") {
candidate /= "Contents/MacOS/Spine";
} else if (fs::is_directory(candidate, error)) {
const auto appCandidate = candidate / "Spine.app/Contents/MacOS/Spine";
if (fs::is_regular_file(appCandidate, error)) {
candidate = appCandidate;
}
}
#elif defined(_WIN32)
if (fs::is_directory(candidate, error)) {
candidate /= "Spine.com";
} else if (candidate.extension() == ".exe") {
auto commandCandidate = candidate;
commandCandidate.replace_extension(".com");
if (fs::is_regular_file(commandCandidate, error)) {
candidate = commandCandidate;
}
}
#endif
if (!fs::is_regular_file(candidate, error)) {
return std::nullopt;
}
const auto canonical = fs::weakly_canonical(candidate, error);
return error ? std::optional(candidate.lexically_normal()) : std::optional(canonical);
}
} // namespace dts
@@ -0,0 +1,17 @@
#pragma once
#include <filesystem>
#include <optional>
#include <vector>
namespace dts {
class SpineEditorLocator {
public:
[[nodiscard]] std::vector<std::filesystem::path> discover() const;
[[nodiscard]] std::optional<std::filesystem::path> normalize(
const std::filesystem::path& userPath) const;
};
} // namespace dts
@@ -0,0 +1,80 @@
#include "editor/SpineOutputParser.hpp"
#include <regex>
namespace dts {
namespace {
std::optional<SpineVersion> firstVersion(
const std::string& text,
const std::regex& expression,
std::string* trailing = nullptr) {
std::smatch match;
if (!std::regex_search(text, match, expression)) {
return std::nullopt;
}
if (trailing && match.size() > 2) {
*trailing = match[2].str();
}
return SpineVersion::parse(match[1].str());
}
} // namespace
EditorProbeResult SpineOutputParser::parse(
const std::filesystem::path& executable,
const ProcessResult& process) const {
EditorProbeResult result;
result.executable = executable;
result.timedOut = process.timedOut;
result.exitCode = process.exitCode;
result.output = process.output;
if (!process.started) {
result.status = EditorProbeStatus::LaunchFailed;
result.message = process.error.empty() ? "Spine process could not be started." : process.error;
return result;
}
static const std::regex launcher(R"(Spine Launcher\s+(\d+\.\d+(?:\.\d+)?))");
static const std::regex launching(
R"((?:Launching:|Starting:)\s*Spine\s+(\d+\.\d+(?:\.\d+)?)(?:\s+([^\r\n]+))?)");
static const std::regex directEditor(
R"(^Spine\s+(\d+\.\d+(?:\.\d+)?)(?:\s+([^\r\n]+))?)",
std::regex_constants::multiline);
result.launcherVersion = firstVersion(process.output, launcher);
result.editorVersion = firstVersion(process.output, launching, &result.edition);
if (!result.editorVersion) {
result.editorVersion = firstVersion(process.output, directEditor, &result.edition);
}
if (!result.launcherVersion && !result.editorVersion) {
result.status = EditorProbeStatus::UnrecognizedOutput;
result.message = "Process started, but its output was not recognized as Spine.";
} else if (process.timedOut) {
result.status = EditorProbeStatus::ObservedButTimedOut;
result.message = "Spine was detected, but the version probe timed out.";
} else if (process.exitCode == 0) {
result.status = EditorProbeStatus::Available;
result.message = "Spine CLI probe succeeded.";
} else {
result.status = EditorProbeStatus::LaunchFailed;
result.message = "Spine was detected, but the process returned a failure code.";
}
return result;
}
std::string toString(EditorProbeStatus status) {
switch (status) {
case EditorProbeStatus::Available: return "available";
case EditorProbeStatus::ObservedButTimedOut: return "observed-but-timed-out";
case EditorProbeStatus::PathMissing: return "path-missing";
case EditorProbeStatus::LaunchFailed: return "launch-failed";
case EditorProbeStatus::UnrecognizedOutput: return "unrecognized-output";
}
return "unknown";
}
} // namespace dts
@@ -0,0 +1,42 @@
#pragma once
#include "domain/SpineVersion.hpp"
#include "platform/ProcessRunner.hpp"
#include <filesystem>
#include <optional>
#include <string>
namespace dts {
enum class EditorProbeStatus {
Available,
ObservedButTimedOut,
PathMissing,
LaunchFailed,
UnrecognizedOutput
};
struct EditorProbeResult {
std::filesystem::path executable;
EditorProbeStatus status = EditorProbeStatus::UnrecognizedOutput;
std::optional<SpineVersion> launcherVersion;
std::optional<SpineVersion> editorVersion;
std::string edition;
bool timedOut = false;
int exitCode = -1;
std::string output;
std::string message;
};
class SpineOutputParser {
public:
[[nodiscard]] EditorProbeResult parse(
const std::filesystem::path& executable,
const ProcessResult& process) const;
};
[[nodiscard]] std::string toString(EditorProbeStatus status);
} // namespace dts
+31
View File
@@ -0,0 +1,31 @@
#include "editor/SpineProbe.hpp"
#include <system_error>
namespace dts {
SpineProbe::SpineProbe(const IProcessRunner& processRunner)
: processRunner_(processRunner) {}
EditorProbeResult SpineProbe::inspect(
const std::filesystem::path& executable,
std::chrono::milliseconds timeout) const {
std::error_code error;
if (!std::filesystem::is_regular_file(executable, error)) {
EditorProbeResult result;
result.executable = executable;
result.status = EditorProbeStatus::PathMissing;
result.message = "Spine executable does not exist.";
return result;
}
ProcessRequest request;
request.program = executable;
request.arguments = {"--version"};
request.workingDirectory = executable.parent_path();
request.timeout = timeout;
return parser_.parse(executable, processRunner_.run(request));
}
} // namespace dts
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include "editor/SpineOutputParser.hpp"
#include "platform/ProcessRunner.hpp"
#include <chrono>
#include <filesystem>
namespace dts {
class SpineProbe {
public:
explicit SpineProbe(const IProcessRunner& processRunner);
[[nodiscard]] EditorProbeResult inspect(
const std::filesystem::path& executable,
std::chrono::milliseconds timeout = std::chrono::seconds(10)) const;
private:
const IProcessRunner& processRunner_;
SpineOutputParser parser_;
};
} // namespace dts
@@ -0,0 +1,60 @@
#include "editor/SpineVersionAvailability.hpp"
#include <algorithm>
#include <cstdlib>
#include <system_error>
namespace dts {
namespace fs = std::filesystem;
std::vector<fs::path> SpineVersionAvailability::defaultCacheRoots() const {
std::vector<fs::path> roots;
#if defined(__APPLE__)
if (const char* userHome = std::getenv("HOME"))
roots.emplace_back(fs::path(userHome) / "Library/Application Support/Spine/updates");
#elif defined(_WIN32)
if (const char* roaming = std::getenv("APPDATA"))
roots.emplace_back(fs::path(roaming) / "Spine/updates");
if (const char* local = std::getenv("LOCALAPPDATA"))
roots.emplace_back(fs::path(local) / "Spine/updates");
#endif
return roots;
}
std::vector<SpineVersion> SpineVersionAvailability::discover(
const std::vector<fs::path>& cacheRoots) const {
std::vector<SpineVersion> versions;
const auto roots = cacheRoots.empty() ? defaultCacheRoots() : cacheRoots;
for (const auto& root : roots) {
std::error_code error;
fs::directory_iterator iterator(root, error);
for (const auto& entry : iterator) {
if (!entry.is_regular_file(error) || entry.file_size(error) == 0) continue;
const auto parsed = SpineVersion::parse(entry.path().filename().string());
if (parsed && std::find(versions.begin(), versions.end(), *parsed) == versions.end())
versions.push_back(*parsed);
}
}
std::sort(versions.begin(), versions.end(), [](const auto& left, const auto& right) {
if (left.major != right.major) return left.major < right.major;
if (left.minor != right.minor) return left.minor < right.minor;
return left.patch < right.patch;
});
return versions;
}
std::optional<SpineVersion> SpineVersionAvailability::newestForLine(
std::string_view versionLine, const std::vector<fs::path>& cacheRoots) const {
std::optional<SpineVersion> result;
for (const auto& version : discover(cacheRoots))
if (version.line() == versionLine) result = version;
return result;
}
bool SpineVersionAvailability::isCached(
const SpineVersion& version, const std::vector<fs::path>& cacheRoots) const {
const auto versions = discover(cacheRoots);
return std::find(versions.begin(), versions.end(), version) != versions.end();
}
} // namespace dts
@@ -0,0 +1,25 @@
#pragma once
#include "domain/SpineVersion.hpp"
#include <filesystem>
#include <optional>
#include <string_view>
#include <vector>
namespace dts {
class SpineVersionAvailability {
public:
[[nodiscard]] std::vector<std::filesystem::path> defaultCacheRoots() const;
[[nodiscard]] std::vector<SpineVersion> discover(
const std::vector<std::filesystem::path>& cacheRoots = {}) const;
[[nodiscard]] std::optional<SpineVersion> newestForLine(
std::string_view versionLine,
const std::vector<std::filesystem::path>& cacheRoots = {}) const;
[[nodiscard]] bool isCached(
const SpineVersion& version,
const std::vector<std::filesystem::path>& cacheRoots = {}) const;
};
} // namespace dts
@@ -0,0 +1,35 @@
#pragma once
#include <chrono>
#include <filesystem>
#include <memory>
#include <string>
#include <vector>
namespace dts {
struct ProcessRequest {
std::filesystem::path program;
std::vector<std::string> arguments;
std::filesystem::path workingDirectory;
std::chrono::milliseconds timeout{10000};
};
struct ProcessResult {
bool started = false;
bool timedOut = false;
int exitCode = -1;
std::string output;
std::string error;
};
class IProcessRunner {
public:
virtual ~IProcessRunner() = default;
[[nodiscard]] virtual ProcessResult run(const ProcessRequest& request) const = 0;
};
[[nodiscard]] std::unique_ptr<IProcessRunner> createDefaultProcessRunner();
} // namespace dts
@@ -0,0 +1,134 @@
#include "platform/ProcessRunner.hpp"
#include <cerrno>
#include <chrono>
#include <csignal>
#include <cstring>
#include <fcntl.h>
#include <memory>
#include <poll.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <thread>
#include <unistd.h>
namespace dts {
namespace {
void appendAvailableOutput(int descriptor, std::string& output) {
char buffer[4096];
while (true) {
const auto count = ::read(descriptor, buffer, sizeof(buffer));
if (count > 0) {
output.append(buffer, static_cast<std::size_t>(count));
continue;
}
if (count < 0 && errno == EINTR) {
continue;
}
break;
}
}
class PosixProcessRunner final : public IProcessRunner {
public:
ProcessResult run(const ProcessRequest& request) const override {
ProcessResult result;
int outputPipe[2];
if (::pipe(outputPipe) != 0) {
result.error = std::strerror(errno);
return result;
}
const pid_t child = ::fork();
if (child < 0) {
result.error = std::strerror(errno);
::close(outputPipe[0]);
::close(outputPipe[1]);
return result;
}
if (child == 0) {
::setpgid(0, 0);
::close(outputPipe[0]);
::dup2(outputPipe[1], STDOUT_FILENO);
::dup2(outputPipe[1], STDERR_FILENO);
::close(outputPipe[1]);
if (!request.workingDirectory.empty()) {
::chdir(request.workingDirectory.c_str());
}
std::vector<std::string> values;
values.reserve(request.arguments.size() + 1);
values.push_back(request.program.string());
values.insert(values.end(), request.arguments.begin(), request.arguments.end());
std::vector<char*> arguments;
arguments.reserve(values.size() + 1);
for (auto& value : values) {
arguments.push_back(value.data());
}
arguments.push_back(nullptr);
::execv(request.program.c_str(), arguments.data());
::_exit(127);
}
result.started = true;
::setpgid(child, child);
::close(outputPipe[1]);
const auto flags = ::fcntl(outputPipe[0], F_GETFL, 0);
::fcntl(outputPipe[0], F_SETFL, flags | O_NONBLOCK);
const auto deadline = std::chrono::steady_clock::now() + request.timeout;
int status = 0;
bool finished = false;
while (!finished) {
appendAvailableOutput(outputPipe[0], result.output);
const auto waitResult = ::waitpid(child, &status, WNOHANG);
if (waitResult == child) {
finished = true;
break;
}
if (waitResult < 0) {
result.error = std::strerror(errno);
break;
}
if (std::chrono::steady_clock::now() >= deadline) {
result.timedOut = true;
::kill(-child, SIGTERM);
for (int attempt = 0; attempt < 25; ++attempt) {
if (::waitpid(child, &status, WNOHANG) == child) {
finished = true;
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
if (!finished) {
::kill(-child, SIGKILL);
::waitpid(child, &status, 0);
}
break;
}
pollfd descriptor{outputPipe[0], POLLIN, 0};
::poll(&descriptor, 1, 25);
}
appendAvailableOutput(outputPipe[0], result.output);
::close(outputPipe[0]);
if (WIFEXITED(status)) {
result.exitCode = WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
result.exitCode = 128 + WTERMSIG(status);
}
return result;
}
};
} // namespace
std::unique_ptr<IProcessRunner> createDefaultProcessRunner() {
return std::make_unique<PosixProcessRunner>();
}
} // namespace dts
@@ -0,0 +1,132 @@
#include "platform/ProcessRunner.hpp"
#define NOMINMAX
#include <windows.h>
#include <memory>
#include <sstream>
namespace dts {
namespace {
std::wstring widen(const std::string& value) {
if (value.empty()) {
return {};
}
const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(),
static_cast<int>(value.size()), nullptr, 0);
std::wstring output(static_cast<std::size_t>(size), L'\0');
MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()),
output.data(), size);
return output;
}
std::wstring quote(const std::wstring& value) {
if (value.find_first_of(L" \t\"") == std::wstring::npos) {
return value;
}
std::wstring output = L"\"";
std::size_t slashes = 0;
for (const auto character : value) {
if (character == L'\\') {
++slashes;
} else if (character == L'\"') {
output.append(slashes * 2 + 1, L'\\');
output += character;
slashes = 0;
} else {
output.append(slashes, L'\\');
output += character;
slashes = 0;
}
}
output.append(slashes * 2, L'\\');
return output + L"\"";
}
std::wstring commandLine(const ProcessRequest& request) {
std::wstring value = quote(request.program.wstring());
for (const auto& argument : request.arguments) {
value += L" " + quote(widen(argument));
}
return value;
}
class WindowsProcessRunner final : public IProcessRunner {
public:
ProcessResult run(const ProcessRequest& request) const override {
ProcessResult result;
SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
HANDLE readPipe = nullptr;
HANDLE writePipe = nullptr;
if (!CreatePipe(&readPipe, &writePipe, &attributes, 0)) {
result.error = "CreatePipe failed.";
return result;
}
SetHandleInformation(readPipe, HANDLE_FLAG_INHERIT, 0);
STARTUPINFOW startup{};
startup.cb = sizeof(startup);
startup.dwFlags = STARTF_USESTDHANDLES;
startup.hStdOutput = writePipe;
startup.hStdError = writePipe;
startup.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
PROCESS_INFORMATION process{};
auto command = commandLine(request);
const auto directory = request.workingDirectory.wstring();
const BOOL created = CreateProcessW(request.program.wstring().c_str(), command.data(),
nullptr, nullptr, TRUE, CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP, nullptr,
directory.empty() ? nullptr : directory.c_str(), &startup, &process);
CloseHandle(writePipe);
if (!created) {
CloseHandle(readPipe);
result.error = "CreateProcessW failed: " + std::to_string(GetLastError());
return result;
}
result.started = true;
HANDLE job = CreateJobObjectW(nullptr, nullptr);
if (job) {
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits));
AssignProcessToJobObject(job, process.hProcess);
}
const DWORD waitTime = static_cast<DWORD>(request.timeout.count());
if (WaitForSingleObject(process.hProcess, waitTime) == WAIT_TIMEOUT) {
result.timedOut = true;
if (job) {
TerminateJobObject(job, 1);
} else {
TerminateProcess(process.hProcess, 1);
}
WaitForSingleObject(process.hProcess, 1000);
}
char buffer[4096];
DWORD count = 0;
while (ReadFile(readPipe, buffer, sizeof(buffer), &count, nullptr) && count > 0) {
result.output.append(buffer, count);
}
DWORD exitCode = 1;
GetExitCodeProcess(process.hProcess, &exitCode);
result.exitCode = static_cast<int>(exitCode);
CloseHandle(readPipe);
if (job) {
CloseHandle(job);
}
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
return result;
}
};
} // namespace
std::unique_ptr<IProcessRunner> createDefaultProcessRunner() {
return std::make_unique<WindowsProcessRunner>();
}
} // namespace dts
+282
View File
@@ -0,0 +1,282 @@
#include "preview/RuntimePlugin.hpp"
#include <cstring>
#include <utility>
#if defined(_WIN32)
#define NOMINMAX
#include <windows.h>
#else
#include <dlfcn.h>
#endif
namespace dts {
namespace {
void* openLibrary(const std::filesystem::path& path, std::string& error) {
#if defined(_WIN32)
auto* handle = LoadLibraryW(path.wstring().c_str());
if (!handle) {
error = "LoadLibraryW failed: " + std::to_string(GetLastError());
}
return handle;
#else
auto* handle = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL);
if (!handle) {
error = dlerror();
}
return handle;
#endif
}
void closeLibrary(void* handle) {
#if defined(_WIN32)
FreeLibrary(static_cast<HMODULE>(handle));
#else
dlclose(handle);
#endif
}
void* findSymbol(void* handle, const char* name) {
#if defined(_WIN32)
return reinterpret_cast<void*>(GetProcAddress(static_cast<HMODULE>(handle), name));
#else
return dlsym(handle, name);
#endif
}
template <typename Function>
Function functionPointer(void* symbol) {
Function function = nullptr;
static_assert(sizeof(function) == sizeof(symbol));
std::memcpy(&function, &symbol, sizeof(function));
return function;
}
std::string utf8Path(const std::filesystem::path& path) {
const auto value = path.u8string();
return {reinterpret_cast<const char*>(value.data()), value.size()};
}
} // namespace
RuntimePlugin::~RuntimePlugin() {
unload();
}
RuntimePlugin::RuntimePlugin(RuntimePlugin&& other) noexcept {
*this = std::move(other);
}
RuntimePlugin& RuntimePlugin::operator=(RuntimePlugin&& other) noexcept {
if (this != &other) {
unload();
handle_ = std::exchange(other.handle_, nullptr);
getApiVersion_ = std::exchange(other.getApiVersion_, nullptr);
getInfo_ = std::exchange(other.getInfo_, nullptr);
create_ = std::exchange(other.create_, nullptr);
destroy_ = std::exchange(other.destroy_, nullptr);
load_ = std::exchange(other.load_, nullptr);
getSkeletonInfo_ = std::exchange(other.getSkeletonInfo_, nullptr);
getAnimationName_ = std::exchange(other.getAnimationName_, nullptr);
getAnimationDuration_ = std::exchange(other.getAnimationDuration_, nullptr);
getSkinName_ = std::exchange(other.getSkinName_, nullptr);
getLastError_ = std::exchange(other.getLastError_, nullptr);
setAnimation_ = std::exchange(other.setAnimation_, nullptr);
update_ = std::exchange(other.update_, nullptr);
getDrawCommandCount_ = std::exchange(other.getDrawCommandCount_, nullptr);
getDrawCommand_ = std::exchange(other.getDrawCommand_, nullptr);
context_ = std::exchange(other.context_, nullptr);
}
return *this;
}
bool RuntimePlugin::load(const std::filesystem::path& path, std::string& error) {
unload();
handle_ = openLibrary(path, error);
if (!handle_) {
return false;
}
getApiVersion_ = functionPointer<GetApiVersion>(findSymbol(handle_, "dtsRuntimeGetApiVersion"));
getInfo_ = functionPointer<GetInfo>(findSymbol(handle_, "dtsRuntimeGetInfo"));
if (!getApiVersion_ || !getInfo_) {
error = "Runtime plugin does not expose the required DateToSpine ABI.";
unload();
return false;
}
if (getApiVersion_() != DTS_RUNTIME_API_VERSION) {
error = "Runtime plugin API version is incompatible.";
unload();
return false;
}
create_ = functionPointer<Create>(findSymbol(handle_, "dtsRuntimeCreate"));
destroy_ = functionPointer<Destroy>(findSymbol(handle_, "dtsRuntimeDestroy"));
load_ = functionPointer<Load>(findSymbol(handle_, "dtsRuntimeLoad"));
getSkeletonInfo_ = functionPointer<GetSkeletonInfo>(
findSymbol(handle_, "dtsRuntimeGetSkeletonInfo"));
getAnimationName_ = functionPointer<GetName>(
findSymbol(handle_, "dtsRuntimeGetAnimationName"));
getAnimationDuration_ = functionPointer<GetDuration>(
findSymbol(handle_, "dtsRuntimeGetAnimationDuration"));
getSkinName_ = functionPointer<GetName>(findSymbol(handle_, "dtsRuntimeGetSkinName"));
getLastError_ = functionPointer<GetLastError>(findSymbol(handle_, "dtsRuntimeGetLastError"));
setAnimation_ = functionPointer<SetAnimation>(findSymbol(handle_, "dtsRuntimeSetAnimation"));
update_ = functionPointer<Update>(findSymbol(handle_, "dtsRuntimeUpdate"));
getDrawCommandCount_ = functionPointer<GetDrawCommandCount>(
findSymbol(handle_, "dtsRuntimeGetDrawCommandCount"));
getDrawCommand_ = functionPointer<GetDrawCommand>(
findSymbol(handle_, "dtsRuntimeGetDrawCommand"));
return true;
}
void RuntimePlugin::unload() {
if (context_ && destroy_) {
destroy_(context_);
}
context_ = nullptr;
getApiVersion_ = nullptr;
getInfo_ = nullptr;
create_ = nullptr;
destroy_ = nullptr;
load_ = nullptr;
getSkeletonInfo_ = nullptr;
getAnimationName_ = nullptr;
getAnimationDuration_ = nullptr;
getSkinName_ = nullptr;
getLastError_ = nullptr;
setAnimation_ = nullptr;
update_ = nullptr;
getDrawCommandCount_ = nullptr;
getDrawCommand_ = nullptr;
if (handle_) {
closeLibrary(handle_);
handle_ = nullptr;
}
}
bool RuntimePlugin::isLoaded() const {
return handle_ != nullptr;
}
std::optional<RuntimePluginMetadata> RuntimePlugin::metadata(std::string& error) const {
if (!getInfo_) {
error = "Runtime plugin is not loaded.";
return std::nullopt;
}
DtsRuntimeInfo info{};
info.structSize = sizeof(info);
if (getInfo_(&info) != 0 || !info.spineVersionLine || !info.adapterBuild) {
error = "Runtime plugin returned invalid metadata.";
return std::nullopt;
}
return RuntimePluginMetadata{
info.apiVersion,
info.spineVersionLine,
info.adapterBuild,
info.capabilities
};
}
std::optional<SkeletonMetadata> RuntimePlugin::loadSkeleton(
const std::filesystem::path& skeletonPath,
const std::filesystem::path& atlasPath,
float scale,
std::string& error) {
if (!create_ || !destroy_ || !load_ || !getSkeletonInfo_ || !getAnimationName_
|| !getAnimationDuration_ || !getSkinName_ || !getLastError_) {
error = "Runtime plugin does not provide skeleton metadata capability.";
return std::nullopt;
}
if (context_) {
destroy_(context_);
}
context_ = create_();
if (!context_) {
error = "Runtime plugin could not create a context.";
return std::nullopt;
}
const auto skeleton = utf8Path(skeletonPath);
const auto atlas = utf8Path(atlasPath);
DtsLoadRequest request{sizeof(DtsLoadRequest), skeleton.c_str(), atlas.c_str(), scale};
if (load_(context_, &request) != 0) {
const auto* detail = getLastError_(context_);
error = detail ? detail : "Runtime plugin failed to load skeleton data.";
return std::nullopt;
}
DtsSkeletonInfo info{};
info.structSize = sizeof(info);
if (getSkeletonInfo_(context_, &info) != 0) {
error = "Runtime plugin failed to return skeleton metadata.";
return std::nullopt;
}
SkeletonMetadata result;
result.boneCount = info.boneCount;
result.slotCount = info.slotCount;
result.eventCount = info.eventCount;
result.constraintCount = info.constraintCount;
result.animations.reserve(info.animationCount);
for (std::uint32_t index = 0; index < info.animationCount; ++index) {
const auto* name = getAnimationName_(context_, index);
result.animations.push_back({name ? name : "", getAnimationDuration_(context_, index)});
}
result.skins.reserve(info.skinCount);
for (std::uint32_t index = 0; index < info.skinCount; ++index) {
const auto* name = getSkinName_(context_, index);
result.skins.emplace_back(name ? name : "");
}
return result;
}
bool RuntimePlugin::setAnimation(const std::string& name, bool loop, std::string& error) {
if (!context_ || !setAnimation_) {
error = "Runtime plugin does not provide animation playback.";
return false;
}
if (setAnimation_(context_, name.c_str(), loop ? 1 : 0) != 0) {
const auto* detail = getLastError_ ? getLastError_(context_) : nullptr;
error = detail ? detail : "Animation could not be selected.";
return false;
}
return true;
}
std::optional<std::vector<PreviewDrawCommand>> RuntimePlugin::updatePreview(
float deltaSeconds, std::string& error) {
if (!context_ || !update_ || !getDrawCommandCount_ || !getDrawCommand_) {
error = "Runtime plugin does not provide preview drawing.";
return std::nullopt;
}
if (update_(context_, deltaSeconds) != 0) {
const auto* detail = getLastError_ ? getLastError_(context_) : nullptr;
error = detail ? detail : "Preview frame could not be sampled.";
return std::nullopt;
}
std::vector<PreviewDrawCommand> result;
const auto count = getDrawCommandCount_(context_);
result.reserve(count);
for (std::uint32_t index = 0; index < count; ++index) {
DtsDrawCommand source{};
source.structSize = sizeof(source);
if (getDrawCommand_(context_, index, &source) != 0 || !source.texturePathUtf8) {
error = "Runtime plugin returned an invalid draw command.";
return std::nullopt;
}
PreviewDrawCommand command;
command.texturePath = std::filesystem::path(
std::u8string(reinterpret_cast<const char8_t*>(source.texturePathUtf8)));
command.blendMode = source.blendMode;
command.indices.assign(source.indices, source.indices + source.indexCount);
command.vertices.reserve(source.vertexCount);
for (std::uint32_t vertex = 0; vertex < source.vertexCount; ++vertex) {
const auto& value = source.vertices[vertex];
command.vertices.push_back({value.x, value.y, value.u, value.v, value.color});
}
result.push_back(std::move(command));
}
return result;
}
} // namespace dts
+105
View File
@@ -0,0 +1,105 @@
#pragma once
#include "RuntimeApi.h"
#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
namespace dts {
struct RuntimePluginMetadata {
std::uint32_t apiVersion = 0;
std::string spineVersionLine;
std::string adapterBuild;
std::uint32_t capabilities = 0;
};
struct AnimationMetadata {
std::string name;
float duration = 0;
};
struct SkeletonMetadata {
std::uint32_t boneCount = 0;
std::uint32_t slotCount = 0;
std::uint32_t eventCount = 0;
std::uint32_t constraintCount = 0;
std::vector<std::string> skins;
std::vector<AnimationMetadata> animations;
};
struct PreviewVertex {
float x = 0;
float y = 0;
float u = 0;
float v = 0;
std::uint32_t color = 0xffffffff;
};
struct PreviewDrawCommand {
std::vector<PreviewVertex> vertices;
std::vector<std::uint16_t> indices;
std::filesystem::path texturePath;
std::uint32_t blendMode = 0;
};
class RuntimePlugin {
public:
RuntimePlugin() = default;
~RuntimePlugin();
RuntimePlugin(const RuntimePlugin&) = delete;
RuntimePlugin& operator=(const RuntimePlugin&) = delete;
RuntimePlugin(RuntimePlugin&& other) noexcept;
RuntimePlugin& operator=(RuntimePlugin&& other) noexcept;
[[nodiscard]] bool load(const std::filesystem::path& path, std::string& error);
void unload();
[[nodiscard]] bool isLoaded() const;
[[nodiscard]] std::optional<RuntimePluginMetadata> metadata(std::string& error) const;
[[nodiscard]] std::optional<SkeletonMetadata> loadSkeleton(
const std::filesystem::path& skeletonPath,
const std::filesystem::path& atlasPath,
float scale,
std::string& error);
[[nodiscard]] bool setAnimation(const std::string& name, bool loop, std::string& error);
[[nodiscard]] std::optional<std::vector<PreviewDrawCommand>> updatePreview(
float deltaSeconds, std::string& error);
private:
using GetApiVersion = std::uint32_t (*)();
using GetInfo = std::int32_t (*)(DtsRuntimeInfo*);
using Create = DtsRuntimeHandle (*)();
using Destroy = void (*)(DtsRuntimeHandle);
using Load = std::int32_t (*)(DtsRuntimeHandle, const DtsLoadRequest*);
using GetSkeletonInfo = std::int32_t (*)(DtsRuntimeHandle, DtsSkeletonInfo*);
using GetName = const char* (*)(DtsRuntimeHandle, std::uint32_t);
using GetDuration = float (*)(DtsRuntimeHandle, std::uint32_t);
using GetLastError = const char* (*)(DtsRuntimeHandle);
using SetAnimation = std::int32_t (*)(DtsRuntimeHandle, const char*, std::int32_t);
using Update = std::int32_t (*)(DtsRuntimeHandle, float);
using GetDrawCommandCount = std::uint32_t (*)(DtsRuntimeHandle);
using GetDrawCommand = std::int32_t (*)(DtsRuntimeHandle, std::uint32_t, DtsDrawCommand*);
void* handle_ = nullptr;
GetApiVersion getApiVersion_ = nullptr;
GetInfo getInfo_ = nullptr;
Create create_ = nullptr;
Destroy destroy_ = nullptr;
Load load_ = nullptr;
GetSkeletonInfo getSkeletonInfo_ = nullptr;
GetName getAnimationName_ = nullptr;
GetDuration getAnimationDuration_ = nullptr;
GetName getSkinName_ = nullptr;
GetLastError getLastError_ = nullptr;
SetAnimation setAnimation_ = nullptr;
Update update_ = nullptr;
GetDrawCommandCount getDrawCommandCount_ = nullptr;
GetDrawCommand getDrawCommand_ = nullptr;
DtsRuntimeHandle context_ = nullptr;
};
} // namespace dts
@@ -0,0 +1,141 @@
#include "scan/SpineDataVersionDetector.hpp"
#include <cstdint>
#include <fstream>
#include <regex>
namespace dts {
namespace {
constexpr std::size_t maxJsonProbeBytes = 1024 * 1024;
constexpr std::size_t maxBinaryStringBytes = 4096;
std::optional<std::uint32_t> readVarint(std::istream& input) {
std::uint32_t value = 0;
for (int shift = 0; shift < 35; shift += 7) {
const int byte = input.get();
if (byte == std::char_traits<char>::eof()) {
return std::nullopt;
}
value |= static_cast<std::uint32_t>(byte & 0x7f) << shift;
if ((byte & 0x80) == 0) {
return value;
}
}
return std::nullopt;
}
std::optional<std::string> readBinaryString(std::istream& input) {
const auto encodedLength = readVarint(input);
if (!encodedLength || *encodedLength == 0) {
return std::nullopt;
}
const std::size_t length = *encodedLength - 1;
if (length > maxBinaryStringBytes) {
return std::nullopt;
}
std::string value(length, '\0');
if (length > 0 && !input.read(value.data(), static_cast<std::streamsize>(length))) {
return std::nullopt;
}
return value;
}
VersionDetectionResult finish(SkeletonDataFormat format, const SpineVersion& version) {
VersionDetectionResult result;
result.format = format;
result.version = version;
result.status = version.isSupported()
? VersionDetectionStatus::Detected
: VersionDetectionStatus::UnsupportedVersion;
result.message = version.isSupported()
? "Spine data version detected."
: "Spine data version is outside the supported range.";
return result;
}
VersionDetectionResult inspectJson(std::ifstream& input) {
std::string text(maxJsonProbeBytes, '\0');
input.read(text.data(), static_cast<std::streamsize>(text.size()));
text.resize(static_cast<std::size_t>(input.gcount()));
static const std::regex bones(R"("bones"\s*:)");
static const std::regex version(
R"regex("spine"\s*:\s*"(\d+\.\d+(?:\.\d+)?(?:-[^"]+)?)")regex");
std::smatch match;
if (!std::regex_search(text, bones) || !std::regex_search(text, match, version)) {
return {SkeletonDataFormat::Json, VersionDetectionStatus::InvalidData, std::nullopt,
"JSON does not contain the minimum Spine skeleton schema."};
}
const auto parsed = SpineVersion::parse(match[1].str());
if (!parsed) {
return {SkeletonDataFormat::Json, VersionDetectionStatus::InvalidData, std::nullopt,
"Spine JSON version is invalid."};
}
return finish(SkeletonDataFormat::Json, *parsed);
}
VersionDetectionResult inspectBinary(std::ifstream& input) {
const auto hash = readBinaryString(input);
const auto versionText = readBinaryString(input);
if (hash && versionText) {
if (const auto version = SpineVersion::parse(*versionText))
return finish(SkeletonDataFormat::Binary, *version);
}
input.clear();
input.seekg(0);
std::string header(256, '\0');
input.read(header.data(), static_cast<std::streamsize>(header.size()));
header.resize(static_cast<std::size_t>(input.gcount()));
static const std::regex embeddedVersion(
R"regex((3\.8(?:\.\d+)?(?:-[A-Za-z0-9.-]+)?|4\.[0-3](?:\.\d+)?(?:-[A-Za-z0-9.-]+)?))regex");
std::smatch match;
if (std::regex_search(header, match, embeddedVersion)) {
if (const auto version = SpineVersion::parse(match[1].str()))
return finish(SkeletonDataFormat::Binary, *version);
}
return {SkeletonDataFormat::Binary, VersionDetectionStatus::InvalidData, std::nullopt,
"SKEL header does not contain a valid Spine version."};
}
} // namespace
VersionDetectionResult SpineDataVersionDetector::inspect(const std::filesystem::path& path) const {
std::ifstream input(path, std::ios::binary);
if (!input) {
return {SkeletonDataFormat::Unknown, VersionDetectionStatus::FileMissing, std::nullopt,
"Skeleton data file could not be opened."};
}
char first = '\0';
while (input.get(first)) {
if (first != ' ' && first != '\t' && first != '\r' && first != '\n') {
break;
}
}
input.clear();
input.seekg(0);
return first == '{' ? inspectJson(input) : inspectBinary(input);
}
std::string toString(SkeletonDataFormat format) {
switch (format) {
case SkeletonDataFormat::Json: return "json";
case SkeletonDataFormat::Binary: return "skel";
case SkeletonDataFormat::Unknown: return "unknown";
}
return "unknown";
}
std::string toString(VersionDetectionStatus status) {
switch (status) {
case VersionDetectionStatus::Detected: return "detected";
case VersionDetectionStatus::FileMissing: return "file-missing";
case VersionDetectionStatus::InvalidData: return "invalid-data";
case VersionDetectionStatus::UnsupportedVersion: return "unsupported-version";
}
return "unknown";
}
} // namespace dts
@@ -0,0 +1,36 @@
#pragma once
#include "domain/SpineVersion.hpp"
#include <filesystem>
#include <optional>
#include <string>
namespace dts {
enum class SkeletonDataFormat { Json, Binary, Unknown };
enum class VersionDetectionStatus {
Detected,
FileMissing,
InvalidData,
UnsupportedVersion
};
struct VersionDetectionResult {
SkeletonDataFormat format = SkeletonDataFormat::Unknown;
VersionDetectionStatus status = VersionDetectionStatus::InvalidData;
std::optional<SpineVersion> version;
std::string message;
};
class SpineDataVersionDetector {
public:
[[nodiscard]] VersionDetectionResult inspect(const std::filesystem::path& path) const;
};
[[nodiscard]] std::string toString(SkeletonDataFormat format);
[[nodiscard]] std::string toString(VersionDetectionStatus status);
} // namespace dts
+437
View File
@@ -0,0 +1,437 @@
#include "ui/MainWindow.hpp"
#include "atlas/AtlasParser.hpp"
#include "conversion/ConversionJob.hpp"
#include "editor/SpineEditorLocator.hpp"
#include "editor/SpineVersionAvailability.hpp"
#include "scan/SpineDataVersionDetector.hpp"
#include "ui/SpinePreviewWidget.hpp"
#include <QComboBox>
#include <QCheckBox>
#include <QCoreApplication>
#include <QDir>
#include <QDirIterator>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QFileDialog>
#include <QFileInfo>
#include <QHBoxLayout>
#include <QLabel>
#include <QMimeData>
#include <QMessageBox>
#include <QPushButton>
#include <QSettings>
#include <QTimer>
#include <QUrl>
#include <QVBoxLayout>
#include <filesystem>
namespace dts {
namespace {
std::filesystem::path localPath(const QString& value) {
#if defined(_WIN32)
return std::filesystem::path(value.toStdWString());
#else
return std::filesystem::path(value.toStdString());
#endif
}
QString platformLibraryName(const QString& line) {
#if defined(_WIN32)
return QStringLiteral("dts-runtime-%1.dll").arg(line);
#elif defined(__APPLE__)
return QStringLiteral("libdts-runtime-%1.dylib").arg(line);
#else
return QStringLiteral("libdts-runtime-%1.so").arg(line);
#endif
}
} // namespace
MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
setupUi();
setAcceptDrops(true);
setWindowTitle(tr("DateToSpine"));
resize(960, 680);
}
void MainWindow::openResourcePath(const QString& path) {
addInput(path);
}
void MainWindow::setupUi() {
auto* root = new QWidget;
auto* layout = new QVBoxLayout(root);
layout->setContentsMargins(28, 24, 28, 24);
layout->setSpacing(16);
auto* top = new QHBoxLayout;
auto* title = new QLabel(tr("DateToSpine"));
title->setObjectName(QStringLiteral("title"));
availabilityLabel_ = new QLabel;
availabilityLabel_->setAlignment(Qt::AlignCenter);
availabilityLabel_->setMinimumWidth(190);
availabilityLabel_->setVisible(false);
resourceCombo_ = new QComboBox;
resourceCombo_->setMinimumWidth(260);
resourceCombo_->setVisible(false);
auto* addButton = new QPushButton(tr("选择资源"));
addButton->setObjectName(QStringLiteral("quietButton"));
top->addWidget(title);
top->addStretch();
top->addWidget(availabilityLabel_);
top->addStretch();
top->addWidget(resourceCombo_);
top->addWidget(addButton);
layout->addLayout(top);
previewWidget_ = new SpinePreviewWidget;
previewWidget_->setObjectName(QStringLiteral("preview"));
layout->addWidget(previewWidget_, 1);
auto* controls = new QHBoxLayout;
animationCombo_ = new QComboBox;
animationCombo_->setMinimumWidth(220);
animationCombo_->setEnabled(false);
statusLabel_ = new QLabel;
statusLabel_->setObjectName(QStringLiteral("status"));
unpremultiplyCheck_ = new QCheckBox(tr("去除预乘 Alpha"));
unpremultiplyCheck_->setToolTip(tr("由 Spine 官方纹理解包器转换为普通透明图片"));
convertButton_ = new QPushButton(tr("一键转换为 .spine"));
convertButton_->setObjectName(QStringLiteral("primaryButton"));
convertButton_->setEnabled(false);
controls->addWidget(new QLabel(tr("动画")));
controls->addWidget(animationCombo_);
controls->addWidget(statusLabel_, 1);
controls->addWidget(unpremultiplyCheck_);
controls->addWidget(convertButton_);
layout->addLayout(controls);
setCentralWidget(root);
setStyleSheet(QStringLiteral(R"(
QMainWindow, QWidget { background: #17191e; color: #f3f4f6; font-size: 14px; }
QLabel#title { font-size: 22px; font-weight: 650; }
QLabel#preview { background: #101217; border: 2px dashed #343944; border-radius: 14px; color: #a7adb8; }
QLabel#status { color: #9299a5; padding-left: 8px; }
QComboBox { background: #242832; border: 1px solid #3a404c; border-radius: 8px; padding: 8px 12px; }
QPushButton { border-radius: 8px; padding: 9px 16px; }
QPushButton#quietButton { background: #292d36; border: 1px solid #3d4350; }
QPushButton#primaryButton { background: #6e5eea; border: none; font-weight: 650; padding: 11px 20px; }
QPushButton#primaryButton:disabled { background: #343743; color: #777d89; }
QCheckBox { spacing: 8px; color: #c9ced8; }
QCheckBox::indicator { width: 18px; height: 18px; }
)"));
showEmptyState();
connect(addButton, &QPushButton::clicked, this, &MainWindow::chooseResources);
connect(resourceCombo_, &QComboBox::currentIndexChanged,
this, &MainWindow::resourceChanged);
connect(animationCombo_, &QComboBox::currentTextChanged,
this, &MainWindow::animationChanged);
connect(convertButton_, &QPushButton::clicked, this, &MainWindow::convertCurrent);
previewTimer_ = new QTimer(this);
previewTimer_->setInterval(16);
connect(previewTimer_, &QTimer::timeout, this, &MainWindow::advancePreview);
}
void MainWindow::showEmptyState() {
previewWidget_->resetViewport();
previewWidget_->setMessage(
tr("把 .json / .skel、.atlas 和图片拖到这里\n\n也可以拖入整个资源文件夹"));
statusLabel_->clear();
}
void MainWindow::chooseResources() {
const auto paths = QFileDialog::getOpenFileNames(this, tr("选择导出资源"), {},
tr("Spine 资源 (*.json *.skel *.atlas *.png *.jpg *.jpeg *.webp);;所有文件 (*)"));
for (const auto& path : paths) addInput(path);
}
void MainWindow::addInput(const QString& path) {
const QFileInfo info(path);
const auto scanDirectory = [this](const QString& directory, bool recursive) {
QDirIterator iterator(directory, {"*.json", "*.skel"}, QDir::Files,
recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags);
while (iterator.hasNext()) addSkeleton(iterator.next());
};
if (info.isDir()) scanDirectory(path, true);
else if (info.suffix().compare("json", Qt::CaseInsensitive) == 0
|| info.suffix().compare("skel", Qt::CaseInsensitive) == 0) addSkeleton(path);
else scanDirectory(info.absolutePath(), false);
scheduleAvailabilityCheck();
}
void MainWindow::addSkeleton(const QString& path) {
const auto result = SpineDataVersionDetector{}.inspect(localPath(path));
if (result.status != VersionDetectionStatus::Detected || skeletonPaths_.contains(path)) return;
skeletonPaths_.append(path);
resourceCombo_->addItem(QFileInfo(path).completeBaseName(), path);
resourceCombo_->setVisible(true);
resourceCombo_->setCurrentIndex(resourceCombo_->count() - 1);
}
QString MainWindow::findAtlas(const QString& skeletonPath) const {
const QFileInfo skeleton(skeletonPath);
QDir directory(skeleton.absolutePath());
const QString base = skeleton.completeBaseName();
for (const auto& name : {base + ".atlas", base + ".atlas.txt"}) {
const auto candidate = directory.filePath(name);
if (QFileInfo::exists(candidate)) return candidate;
}
const auto atlases = directory.entryInfoList({"*.atlas", "*.atlas.txt"}, QDir::Files);
if (atlases.size() == 1) return atlases.front().absoluteFilePath();
for (const auto& atlas : atlases) {
if (base.startsWith(atlas.completeBaseName())
|| atlas.completeBaseName().startsWith(base)) return atlas.absoluteFilePath();
}
return {};
}
QString MainWindow::runtimeLibrary(const QString& versionLine) const {
const QDir executableDirectory(QCoreApplication::applicationDirPath());
#if defined(__APPLE__)
return QDir::cleanPath(executableDirectory.filePath(
"../PlugIns/runtimes/" + platformLibraryName(versionLine)));
#else
return executableDirectory.filePath("runtimes/" + platformLibraryName(versionLine));
#endif
}
void MainWindow::resourceChanged(int index) {
if (index >= 0) {
loadCurrentPreview();
scheduleAvailabilityCheck();
}
}
void MainWindow::loadCurrentPreview() {
previewTimer_->stop();
previewWidget_->resetViewport();
animationCombo_->clear();
animationCombo_->setEnabled(false);
convertButton_->setEnabled(false);
const QString skeletonPath = resourceCombo_->currentData().toString();
const QString atlasPath = findAtlas(skeletonPath);
const auto detection = SpineDataVersionDetector{}.inspect(localPath(skeletonPath));
if (!detection.version || atlasPath.isEmpty()) {
previewWidget_->setMessage(tr("没有找到与骨骼数据配套的 .atlas 文件"));
statusLabel_->setText(tr("请把完整导出资源一起拖入"));
return;
}
currentVersion_ = QString::fromStdString(detection.version->toString());
std::string error;
if (!runtime_.load(localPath(runtimeLibrary(
QString::fromStdString(detection.version->line()))), error)) {
previewWidget_->setMessage(tr("暂时无法加载这套资源"));
statusLabel_->setText(QString::fromStdString(error));
return;
}
const auto metadata = runtime_.loadSkeleton(
localPath(skeletonPath), localPath(atlasPath), 1.0F, error);
if (!metadata) {
previewWidget_->setMessage(tr("此版本的预览模块仍在接入中"));
statusLabel_->setText(tr("资源已识别,可以稍后转换"));
return;
}
for (const auto& animation : metadata->animations)
animationCombo_->addItem(QString::fromStdString(animation.name), animation.duration);
animationCombo_->setEnabled(animationCombo_->count() > 0);
statusLabel_->setText(tr("已识别 %1 个动画").arg(metadata->animations.size()));
if (animationCombo_->count() > 0) {
animationChanged(animationCombo_->itemText(0));
} else {
auto frame = runtime_.updatePreview(0.0F, error);
if (frame) previewWidget_->setFrame(std::move(*frame));
else previewWidget_->setMessage(tr("没有可播放的动画"));
}
}
void MainWindow::animationChanged(const QString& name) {
if (name.isEmpty()) return;
std::string error;
if (!runtime_.setAnimation(name.toStdString(), true, error)) {
statusLabel_->setText(QString::fromStdString(error));
return;
}
advancePreview();
previewTimer_->start();
}
void MainWindow::advancePreview() {
std::string error;
auto frame = runtime_.updatePreview(0.016F, error);
if (!frame) {
previewTimer_->stop();
previewWidget_->setMessage(tr("动画预览暂时不可用"));
statusLabel_->setText(QString::fromStdString(error));
return;
}
previewWidget_->setFrame(std::move(*frame));
}
QString MainWindow::resolveEditor() {
QSettings settings;
SpineEditorLocator locator;
const auto saved = settings.value("spineEditorPath").toString();
if (!saved.isEmpty()) {
if (const auto normalized = locator.normalize(localPath(saved)))
return QString::fromStdString(normalized->string());
}
const auto found = locator.discover();
if (!found.empty()) return QString::fromStdString(found.front().string());
return {};
}
void MainWindow::scheduleAvailabilityCheck() {
if (availabilityCheckScheduled_) return;
availabilityCheckScheduled_ = true;
QTimer::singleShot(0, this, [this] {
availabilityCheckScheduled_ = false;
refreshAvailability();
});
}
void MainWindow::showAvailability(const QString& text, const QString& color) {
availabilityLabel_->setText(QStringLiteral("") + text);
availabilityLabel_->setStyleSheet(QStringLiteral(
"QLabel { color: %1; background: #20242c; border: 1px solid #343a46; "
"border-radius: 8px; padding: 7px 12px; font-weight: 600; }").arg(color));
availabilityLabel_->setVisible(true);
}
void MainWindow::refreshAvailability() {
availableVersions_.clear();
currentEditorVersion_.clear();
editorPath_ = resolveEditor();
SpineVersionAvailability availability;
for (const auto& path : skeletonPaths_) {
const auto detection = SpineDataVersionDetector{}.inspect(localPath(path));
if (!detection.version) continue;
const auto line = QString::fromStdString(detection.version->line());
if (const auto installed = availability.newestForLine(detection.version->line()))
availableVersions_.insert(line, QString::fromStdString(installed->toString()));
}
const auto current = SpineDataVersionDetector{}.inspect(
localPath(resourceCombo_->currentData().toString()));
if (!current.version) return;
const auto atlasPath = findAtlas(resourceCombo_->currentData().toString());
if (atlasPath.isEmpty()) {
showAvailability(tr("缺少配套 Atlas"), QStringLiteral("#ef6b73"));
convertButton_->setText(tr("资源不完整"));
convertButton_->setEnabled(false);
return;
}
const auto atlas = AtlasParser{}.parseFile(localPath(atlasPath));
if (!atlas.document) {
showAvailability(tr("Atlas 无法识别"), QStringLiteral("#ef6b73"));
convertButton_->setText(tr("资源不完整"));
convertButton_->setEnabled(false);
return;
}
const QDir atlasDirectory = QFileInfo(atlasPath).dir();
for (const auto& page : atlas.document->pages) {
const auto pagePath = QString::fromStdString(page.imagePath.string());
if (!QFileInfo::exists(atlasDirectory.filePath(pagePath))) {
showAvailability(tr("缺少纹理页 %1").arg(QFileInfo(pagePath).fileName()),
QStringLiteral("#ef6b73"));
convertButton_->setText(tr("资源不完整"));
convertButton_->setEnabled(false);
return;
}
}
const auto line = QString::fromStdString(current.version->line());
if (editorPath_.isEmpty()) {
showAvailability(tr("未找到 Spine Editor"), QStringLiteral("#f2b84b"));
convertButton_->setText(tr("指定 Spine 路径"));
convertButton_->setEnabled(true);
return;
}
currentEditorVersion_ = availableVersions_.value(line);
if (currentEditorVersion_.isEmpty()) {
showAvailability(tr("缺少 Spine %1").arg(line), QStringLiteral("#ef6b73"));
convertButton_->setText(tr("指定 Spine %1 路径").arg(line));
convertButton_->setEnabled(true);
return;
}
showAvailability(tr("可转换 · Spine %1").arg(currentEditorVersion_),
QStringLiteral("#55d68b"));
convertButton_->setText(tr("一键转换为 .spine"));
convertButton_->setEnabled(true);
}
bool MainWindow::chooseEditorPath() {
const auto selected = QFileDialog::getOpenFileName(this,
tr("指定 Spine 程序"), {}, tr("所有文件 (*)"));
if (selected.isEmpty()) return false;
SpineEditorLocator locator;
const auto normalized = locator.normalize(localPath(selected));
if (!normalized) {
showAvailability(tr("选择的路径不是 Spine Editor"), QStringLiteral("#ef6b73"));
return false;
}
QSettings{}.setValue("spineEditorPath", selected);
editorPath_ = QString::fromStdString(normalized->string());
refreshAvailability();
return !currentEditorVersion_.isEmpty();
}
void MainWindow::convertCurrent() {
if (editorPath_.isEmpty() || currentEditorVersion_.isEmpty())
if (!chooseEditorPath()) return;
const QString input = resourceCombo_->currentData().toString();
const auto outputParent = QFileDialog::getExistingDirectory(this,
tr("选择输出位置"), QFileInfo(input).absolutePath());
if (outputParent.isEmpty()) return;
const auto atlas = findAtlas(input);
if (atlas.isEmpty()) {
QMessageBox::critical(this, tr("转换失败"), tr("没有找到配套的 .atlas 文件。"));
return;
}
ConversionRequest request{
editorPath_, currentEditorVersion_, input, atlas, outputParent,
QFileInfo(input).completeBaseName(), unpremultiplyCheck_->isChecked()};
conversionJob_ = new ConversionJob(std::move(request), this);
connect(conversionJob_, &ConversionJob::finished,
this, &MainWindow::conversionFinished);
setBusy(true);
conversionJob_->start();
}
void MainWindow::setBusy(bool busy) {
convertButton_->setEnabled(!busy);
convertButton_->setText(busy ? tr("正在转换…") : tr("一键转换为 .spine"));
if (busy) showAvailability(tr("正在使用 Spine %1 转换").arg(currentEditorVersion_),
QStringLiteral("#72a7ff"));
}
void MainWindow::conversionFinished(bool success, const QString& outputDirectory,
const QString& error) {
conversionJob_->deleteLater();
conversionJob_ = nullptr;
setBusy(false);
statusLabel_->setText(success ? tr("转换完成") : tr("转换失败"));
statusLabel_->setToolTip(success ? outputDirectory : error);
refreshAvailability();
if (success) {
QMessageBox::information(this, tr("转换完成"),
tr("完整资源已生成:\n%1").arg(outputDirectory));
} else {
QMessageBox::critical(this, tr("转换失败"),
error.isEmpty() ? tr("转换未完成,Spine Editor 未提供详细原因。") : error);
}
}
void MainWindow::dragEnterEvent(QDragEnterEvent* event) {
if (event->mimeData()->hasUrls()) event->acceptProposedAction();
}
void MainWindow::dropEvent(QDropEvent* event) {
for (const auto& url : event->mimeData()->urls())
if (url.isLocalFile()) addInput(url.toLocalFile());
event->acceptProposedAction();
}
} // namespace dts
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include "preview/RuntimePlugin.hpp"
#include <QMainWindow>
#include <QHash>
#include <QStringList>
class QComboBox;
class QCheckBox;
class QLabel;
class QPushButton;
class QTimer;
namespace dts {
class SpinePreviewWidget;
class ConversionJob;
class MainWindow final : public QMainWindow {
Q_OBJECT
public:
explicit MainWindow(QWidget* parent = nullptr);
void openResourcePath(const QString& path);
protected:
void dragEnterEvent(QDragEnterEvent* event) override;
void dropEvent(QDropEvent* event) override;
private slots:
void chooseResources();
void resourceChanged(int index);
void convertCurrent();
void conversionFinished(bool success, const QString& outputDirectory,
const QString& error);
void animationChanged(const QString& name);
void advancePreview();
private:
void setupUi();
void addInput(const QString& path);
void addSkeleton(const QString& path);
void loadCurrentPreview();
QString findAtlas(const QString& skeletonPath) const;
QString runtimeLibrary(const QString& versionLine) const;
QString resolveEditor();
void scheduleAvailabilityCheck();
void refreshAvailability();
bool chooseEditorPath();
void showAvailability(const QString& text, const QString& color);
void showEmptyState();
void setBusy(bool busy);
QComboBox* resourceCombo_ = nullptr;
QComboBox* animationCombo_ = nullptr;
QCheckBox* unpremultiplyCheck_ = nullptr;
SpinePreviewWidget* previewWidget_ = nullptr;
QLabel* statusLabel_ = nullptr;
QLabel* availabilityLabel_ = nullptr;
QPushButton* convertButton_ = nullptr;
QStringList skeletonPaths_;
QString currentVersion_;
QString currentEditorVersion_;
QString editorPath_;
QHash<QString, QString> availableVersions_;
bool availabilityCheckScheduled_ = false;
RuntimePlugin runtime_;
ConversionJob* conversionJob_ = nullptr;
QTimer* previewTimer_ = nullptr;
};
} // namespace dts
+195
View File
@@ -0,0 +1,195 @@
#include "ui/SpinePreviewWidget.hpp"
#include <QApplication>
#include <QColor>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPolygonF>
#include <QTransform>
#include <QWheelEvent>
#include <algorithm>
#include <cmath>
#include <limits>
namespace dts {
namespace {
QString localString(const std::filesystem::path& path) {
#if defined(_WIN32)
return QString::fromStdWString(path.wstring());
#else
const auto text = path.u8string();
return QString::fromUtf8(reinterpret_cast<const char*>(text.data()),
static_cast<qsizetype>(text.size()));
#endif
}
QPainter::CompositionMode compositionMode(std::uint32_t blendMode) {
switch (blendMode) {
case 1: return QPainter::CompositionMode_Plus;
case 2: return QPainter::CompositionMode_Multiply;
case 3: return QPainter::CompositionMode_Screen;
default: return QPainter::CompositionMode_SourceOver;
}
}
} // namespace
SpinePreviewWidget::SpinePreviewWidget(QWidget* parent) : QWidget(parent) {
setMinimumSize(640, 440);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
setAutoFillBackground(false);
setMouseTracking(true);
setToolTip(tr("滚轮缩放 · 中键重置 · 右键拖动 · 右键单击居中"));
}
void SpinePreviewWidget::setFrame(std::vector<PreviewDrawCommand> commands) {
if (!viewBounds_ && !commands.empty()) {
float left = std::numeric_limits<float>::max();
float right = std::numeric_limits<float>::lowest();
float bottom = std::numeric_limits<float>::max();
float top = std::numeric_limits<float>::lowest();
for (const auto& command : commands) for (const auto& vertex : command.vertices) {
left = std::min(left, vertex.x);
right = std::max(right, vertex.x);
bottom = std::min(bottom, vertex.y);
top = std::max(top, vertex.y);
}
if (right > left && top > bottom)
viewBounds_ = QRectF(left, bottom, right - left, top - bottom);
}
commands_ = std::move(commands);
message_.clear();
update();
}
void SpinePreviewWidget::resetViewport() {
viewBounds_.reset();
zoom_ = 1.0F;
pan_ = {};
}
void SpinePreviewWidget::setMessage(const QString& message) {
commands_.clear();
message_ = message;
update();
}
const QImage& SpinePreviewWidget::texture(const std::filesystem::path& path) {
const auto key = localString(path);
auto found = textures_.find(key);
if (found == textures_.end()) found = textures_.insert(key, QImage(key));
return found.value();
}
void SpinePreviewWidget::paintEvent(QPaintEvent*) {
QPainter painter(this);
painter.setRenderHint(QPainter::SmoothPixmapTransform);
painter.fillRect(rect(), QColor("#101217"));
if (commands_.empty()) {
painter.setPen(QColor("#a7adb8"));
painter.drawText(rect(), Qt::AlignCenter | Qt::TextWordWrap, message_);
return;
}
const QRectF bounds = viewBounds_.value_or(QRectF(0, 0, 1, 1));
const float contentWidth = std::max(1.0, bounds.width());
const float contentHeight = std::max(1.0, bounds.height());
const float baseScale = 0.94F * std::min(width() / contentWidth, height() / contentHeight);
const float scale = baseScale * zoom_;
const QPointF center = bounds.center();
const auto toScreen = [this, center, scale](const PreviewVertex& vertex) {
return QPointF(width() * 0.5 + pan_.x() + (vertex.x - center.x()) * scale,
height() * 0.5 + pan_.y() + (vertex.y - center.y()) * scale);
};
for (const auto& command : commands_) {
const auto& image = texture(command.texturePath);
if (image.isNull()) continue;
painter.setCompositionMode(compositionMode(command.blendMode));
for (std::size_t index = 0; index + 2 < command.indices.size(); index += 3) {
const auto i0 = command.indices[index];
const auto i1 = command.indices[index + 1];
const auto i2 = command.indices[index + 2];
if (i0 >= command.vertices.size() || i1 >= command.vertices.size()
|| i2 >= command.vertices.size()) continue;
const auto& v0 = command.vertices[i0];
const auto& v1 = command.vertices[i1];
const auto& v2 = command.vertices[i2];
const QPointF s0(v0.u * image.width(), v0.v * image.height());
const QPointF s1(v1.u * image.width(), v1.v * image.height());
const QPointF s2(v2.u * image.width(), v2.v * image.height());
const QPointF d0 = toScreen(v0);
const QPointF d1 = toScreen(v1);
const QPointF d2 = toScreen(v2);
QPolygonF source{s0, s1, s2, s1 + s2 - s0};
QPolygonF destination{d0, d1, d2, d1 + d2 - d0};
QTransform transform;
if (!QTransform::quadToQuad(source, destination, transform)) continue;
QPainterPath clip;
clip.addPolygon(QPolygonF{d0, d1, d2});
painter.save();
painter.setClipPath(clip);
painter.setOpacity(QColor::fromRgba(v0.color).alphaF());
painter.setTransform(transform);
painter.drawImage(QPointF(0, 0), image);
painter.restore();
}
}
}
void SpinePreviewWidget::wheelEvent(QWheelEvent* event) {
if (commands_.empty() || event->angleDelta().y() == 0) return;
const float previousZoom = zoom_;
const float factor = event->angleDelta().y() > 0 ? 1.12F : 1.0F / 1.12F;
zoom_ = std::clamp(previousZoom * factor, 0.15F, 8.0F);
const QPointF canvasCenter(width() * 0.5, height() * 0.5);
const QPointF cursorOffset = event->position() - canvasCenter;
pan_ = cursorOffset - (cursorOffset - pan_) * (zoom_ / previousZoom);
update();
event->accept();
}
void SpinePreviewWidget::mousePressEvent(QMouseEvent* event) {
if (event->button() == Qt::MiddleButton) {
zoom_ = 1.0F;
pan_ = {};
update();
event->accept();
return;
}
if (event->button() == Qt::RightButton) {
rightPressPosition_ = event->position();
lastMousePosition_ = event->position();
rightDragging_ = false;
setCursor(Qt::ClosedHandCursor);
event->accept();
}
}
void SpinePreviewWidget::mouseMoveEvent(QMouseEvent* event) {
if (!(event->buttons() & Qt::RightButton)) return;
if (!rightDragging_ && (event->position() - rightPressPosition_).manhattanLength()
>= QApplication::startDragDistance()) rightDragging_ = true;
if (rightDragging_) {
pan_ += event->position() - lastMousePosition_;
update();
}
lastMousePosition_ = event->position();
event->accept();
}
void SpinePreviewWidget::mouseReleaseEvent(QMouseEvent* event) {
if (event->button() != Qt::RightButton) return;
unsetCursor();
if (!rightDragging_) {
pan_ = {};
update();
}
rightDragging_ = false;
event->accept();
}
} // namespace dts
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include "preview/RuntimePlugin.hpp"
#include <QHash>
#include <QImage>
#include <QPointF>
#include <QRectF>
#include <QWidget>
#include <optional>
namespace dts {
class SpinePreviewWidget final : public QWidget {
public:
explicit SpinePreviewWidget(QWidget* parent = nullptr);
void setFrame(std::vector<PreviewDrawCommand> commands);
void setMessage(const QString& message);
void resetViewport();
protected:
void paintEvent(QPaintEvent* event) override;
void wheelEvent(QWheelEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
private:
const QImage& texture(const std::filesystem::path& path);
std::vector<PreviewDrawCommand> commands_;
QHash<QString, QImage> textures_;
QString message_;
std::optional<QRectF> viewBounds_;
float zoom_ = 1.0F;
QPointF pan_;
QPointF rightPressPosition_;
QPointF lastMousePosition_;
bool rightDragging_ = false;
};
} // namespace dts
+350
View File
@@ -0,0 +1,350 @@
#include "atlas/AtlasParser.hpp"
#include "domain/SpineVersion.hpp"
#include "editor/SpineEditorLocator.hpp"
#include "editor/SpineOutputParser.hpp"
#include "editor/SpineVersionAvailability.hpp"
#include "platform/ProcessRunner.hpp"
#include "preview/RuntimePlugin.hpp"
#include "scan/SpineDataVersionDetector.hpp"
#include <chrono>
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
namespace {
int failures = 0;
void expect(bool condition, const std::string& message) {
if (!condition) {
std::cerr << "FAILED: " << message << '\n';
++failures;
}
}
void testVersions() {
const auto supported = dts::SpineVersion::parse("3.8.99");
expect(supported.has_value(), "3.8.99 parses");
expect(supported && supported->line() == "3.8", "version line is 3.8");
expect(supported && supported->isSupported(), "3.8.99 is supported");
const auto early = dts::SpineVersion::parse("3.8.19");
expect(early && !early->isSupported(), "3.8.19 is outside formal support");
const auto current = dts::SpineVersion::parse("4.3.6");
expect(current && current->isSupported(), "4.3 is supported");
const auto beta = dts::SpineVersion::parse("4.3.75-beta");
expect(beta && beta->toString() == "4.3.75", "version suffix is accepted");
const auto old = dts::SpineVersion::parse("3.7.94");
expect(old && !old->isSupported(), "3.7 is unsupported");
expect(!dts::SpineVersion::parse("four.two"), "invalid version is rejected");
}
void testLauncherOutput() {
const std::string output =
"Spine Launcher 4.3.06 (macOS Apple Silicon)\n"
"Launching: Spine 3.8.99 Professional\n"
"Starting: Spine 3.8.99\n";
dts::ProcessResult process{true, false, 0, output, {}};
const auto result = dts::SpineOutputParser{}.parse("/Applications/Spine", process);
expect(result.status == dts::EditorProbeStatus::Available, "probe is available");
expect(result.launcherVersion && result.launcherVersion->toString() == "4.3.6",
"launcher version is parsed separately");
expect(result.editorVersion && result.editorVersion->toString() == "3.8.99",
"editor version is parsed separately");
expect(result.edition == "Professional", "edition is parsed");
}
void testTimedOutOutput() {
dts::ProcessResult process;
process.started = true;
process.timedOut = true;
process.exitCode = 143;
process.output = "Spine Launcher 4.3.06\nLaunching: Spine 4.2.83 Professional\n";
const auto result = dts::SpineOutputParser{}.parse("Spine", process);
expect(result.status == dts::EditorProbeStatus::ObservedButTimedOut,
"recognized timeout is distinguished from missing Spine");
}
void testMacBundleNormalization() {
#if defined(__APPLE__)
const auto path = dts::SpineEditorLocator{}.normalize("/Applications/Spine.app");
expect(path.has_value(), "macOS .app is normalized");
expect(path && path->filename() == "Spine", "normalized executable is Spine");
#endif
}
void testProcessTimeout() {
#if !defined(_WIN32)
auto runner = dts::createDefaultProcessRunner();
dts::ProcessRequest request;
request.program = "/bin/sleep";
request.arguments = {"5"};
request.timeout = std::chrono::milliseconds(50);
const auto result = runner->run(request);
expect(result.started, "timeout test process starts");
expect(result.timedOut, "long-running process is timed out");
#endif
}
void writeVarint(std::ofstream& output, std::size_t value) {
while (true) {
auto byte = static_cast<unsigned char>(value & 0x7f);
value >>= 7;
if (value != 0) {
byte |= 0x80;
}
output.put(static_cast<char>(byte));
if (value == 0) {
return;
}
}
}
void writeBinaryString(std::ofstream& output, const std::string& value) {
writeVarint(output, value.size() + 1);
output.write(value.data(), static_cast<std::streamsize>(value.size()));
}
void testDataVersionDetection() {
const auto directory = std::filesystem::temp_directory_path() / "dts-core-tests";
std::filesystem::create_directories(directory);
const auto jsonPath = directory / "skeleton.json";
{
std::ofstream output(jsonPath);
output << R"({"skeleton":{"spine":"4.2.17"},"bones":[{"name":"root"}]})";
}
const auto json = dts::SpineDataVersionDetector{}.inspect(jsonPath);
expect(json.status == dts::VersionDetectionStatus::Detected, "Spine JSON is detected");
expect(json.version && json.version->toString() == "4.2.17", "JSON version is parsed");
const auto binaryPath = directory / "skeleton.skel";
{
std::ofstream output(binaryPath, std::ios::binary);
writeBinaryString(output, "fixture-hash");
writeBinaryString(output, "3.8.99");
}
const auto binary = dts::SpineDataVersionDetector{}.inspect(binaryPath);
expect(binary.status == dts::VersionDetectionStatus::Detected, "SKEL is detected");
expect(binary.version && binary.version->toString() == "3.8.99", "SKEL version is parsed");
const auto genericPath = directory / "generic.json";
{
std::ofstream output(genericPath);
output << R"({"spine":"4.2.0","items":[]})";
}
const auto generic = dts::SpineDataVersionDetector{}.inspect(genericPath);
expect(generic.status == dts::VersionDetectionStatus::InvalidData,
"ordinary JSON is not accepted as Spine data");
std::filesystem::remove_all(directory);
}
void testAtlasParsing() {
const std::string atlas =
"hero.webp\n"
"size: 1024, 512\n"
"filter: Linear, Linear\n"
"scale: 0.5\n"
"pma: true\n"
"body/arm\n"
"bounds: 10, 20, 30, 40\n"
"offsets: 1, 2, 32, 44\n"
"rotate: 90\n"
"\n"
"hero-2.jpg\n"
"size: 256, 256\n"
"face\n"
"xy: 4, 5\n"
"size: 20, 30\n"
"orig: 22, 34\n";
const auto result = dts::AtlasParser{}.parseText(atlas);
expect(result.document.has_value(), "multi-page Atlas parses");
expect(result.document && result.document->pages.size() == 2, "two pages are found");
const auto& first = result.document->pages.front();
expect(first.imagePath == "hero.webp", "WebP page path is retained");
expect(first.premultipliedAlpha == true, "PMA is parsed");
expect(first.scale && std::abs(*first.scale - 0.5) < 0.000001,
"Atlas page scale is parsed");
expect(first.regions.size() == 1 && first.regions.front().rotated,
"rotated region is parsed");
const auto& second = result.document->pages.back();
expect(second.regions.size() == 1 && second.regions.front().size.size() == 2,
"legacy region size is parsed");
}
void testInstalledVersionDiscovery() {
const auto directory = std::filesystem::temp_directory_path() / "dts-version-cache-tests";
std::filesystem::create_directories(directory);
for (const auto* name : {"4.3.18", "4.3.23", "4.2.43", "not-a-version"}) {
std::ofstream output(directory / name, std::ios::binary);
output.put('x');
}
dts::SpineVersionAvailability availability;
const auto selected = availability.newestForLine("4.3", {directory});
expect(selected && selected->toString() == "4.3.23",
"newest locally cached patch is selected for a version line");
expect(selected && availability.isCached(*selected, {directory}),
"selected exact editor version is confirmed in local cache");
expect(!availability.newestForLine("4.1", {directory}),
"missing cached version line is not reported as available");
std::filesystem::remove_all(directory);
}
void expectRuntimePlugin(const std::string& path, const std::string& versionLine) {
dts::RuntimePlugin plugin;
std::string error;
expect(plugin.load(path, error), versionLine + " Runtime plugin loads: " + error);
const auto metadata = plugin.metadata(error);
expect(metadata.has_value(), "Runtime metadata is available: " + error);
expect(metadata && metadata->apiVersion == DTS_RUNTIME_API_VERSION,
"Runtime API version matches");
expect(metadata && metadata->spineVersionLine == versionLine,
"Runtime line is " + versionLine);
}
void testRuntimePlugins() {
#if defined(DTS_RUNTIME_38_PATH)
expectRuntimePlugin(DTS_RUNTIME_38_PATH, "3.8");
expectRuntimePlugin(DTS_RUNTIME_40_PATH, "4.0");
expectRuntimePlugin(DTS_RUNTIME_41_PATH, "4.1");
expectRuntimePlugin(DTS_RUNTIME_42_PATH, "4.2");
expectRuntimePlugin(DTS_RUNTIME_43_PATH, "4.3");
#endif
}
void testRuntime43OfficialAssets() {
#if defined(DTS_TEST_SPINEBOY_JSON)
const auto jsonVersion = dts::SpineDataVersionDetector{}.inspect(DTS_TEST_SPINEBOY_JSON);
expect(jsonVersion.status == dts::VersionDetectionStatus::Detected,
"official 4.3 beta JSON version is detected");
const auto binaryVersion = dts::SpineDataVersionDetector{}.inspect(DTS_TEST_SPINEBOY_SKEL);
expect(binaryVersion.status == dts::VersionDetectionStatus::Detected,
"official 4.3 binary header version is detected");
dts::RuntimePlugin plugin;
std::string error;
expect(plugin.load(DTS_RUNTIME_43_PATH, error), "4.3 Runtime loads for official assets");
const auto json = plugin.loadSkeleton(
DTS_TEST_SPINEBOY_JSON, DTS_TEST_SPINEBOY_ATLAS, 1.0F, error);
expect(json.has_value(), "official Spineboy JSON loads: " + error);
expect(json && json->boneCount > 0, "Spineboy JSON contains bones");
expect(json && !json->animations.empty(), "Spineboy JSON contains animations");
if (json && !json->animations.empty()) {
const auto idle = std::find_if(json->animations.begin(), json->animations.end(),
[](const auto& animation) { return animation.name == "idle"; });
const auto& animated = idle == json->animations.end()
? json->animations.front() : *idle;
expect(plugin.setAnimation(animated.name, true, error),
"Spineboy animation can be selected: " + error);
const auto frame = plugin.updatePreview(1.0F / 60.0F, error);
expect(frame.has_value(), "Spineboy preview frame is sampled: " + error);
expect(frame && !frame->empty(), "Spineboy preview contains draw commands");
if (frame && !frame->empty()) {
expect(!frame->front().vertices.empty(), "draw command contains vertices");
expect(!frame->front().indices.empty(), "draw command contains triangles");
expect(std::filesystem::exists(frame->front().texturePath),
"draw command texture exists");
}
const auto laterFrame = plugin.updatePreview(0.25F, error);
bool moved = false;
if (frame && laterFrame && frame->size() == laterFrame->size()) {
for (std::size_t command = 0; command < frame->size() && !moved; ++command) {
const auto& before = (*frame)[command].vertices;
const auto& after = (*laterFrame)[command].vertices;
for (std::size_t vertex = 0; vertex < std::min(before.size(), after.size()); ++vertex) {
if (std::abs(before[vertex].x - after[vertex].x) > 0.001F
|| std::abs(before[vertex].y - after[vertex].y) > 0.001F) {
moved = true;
break;
}
}
}
}
expect(moved, "animation sampling changes vertex positions over time");
}
error.clear();
const auto binary = plugin.loadSkeleton(
DTS_TEST_SPINEBOY_SKEL, DTS_TEST_SPINEBOY_ATLAS, 1.0F, error);
expect(binary.has_value(), "official Spineboy SKEL loads: " + error);
expect(binary && binary->boneCount > 0, "Spineboy SKEL contains bones");
expect(binary && !binary->animations.empty(), "Spineboy SKEL contains animations");
#endif
}
void testRuntime42OfficialAssets() {
#if defined(DTS_TEST_42_JSON)
dts::RuntimePlugin plugin;
std::string error;
expect(plugin.load(DTS_RUNTIME_42_PATH, error), "4.2 Runtime loads for official assets");
const auto json = plugin.loadSkeleton(DTS_TEST_42_JSON, DTS_TEST_42_ATLAS, 1.0F, error);
expect(json.has_value(), "official 4.2 Spineboy JSON loads: " + error);
expect(json && !json->animations.empty(), "official 4.2 JSON contains animations");
if (json && !json->animations.empty()) {
expect(plugin.setAnimation(json->animations.front().name, true, error),
"4.2 animation can be selected: " + error);
const auto frame = plugin.updatePreview(1.0F / 60.0F, error);
expect(frame && !frame->empty(), "4.2 preview produces draw commands: " + error);
}
error.clear();
const auto binary = plugin.loadSkeleton(DTS_TEST_42_SKEL, DTS_TEST_42_ATLAS, 1.0F, error);
expect(binary.has_value(), "official 4.2 Spineboy SKEL loads: " + error);
#endif
}
void testLegacyOfficialAsset(const char* line, const char* runtimePath,
const char* jsonPath, const char* binaryPath, const char* atlasPath) {
dts::RuntimePlugin plugin;
std::string error;
expect(plugin.load(runtimePath, error), std::string(line) + " Runtime loads for assets");
const auto json = plugin.loadSkeleton(jsonPath, atlasPath, 1.0F, error);
expect(json.has_value(), std::string("official ") + line + " JSON loads: " + error);
expect(json && !json->animations.empty(), std::string(line) + " JSON contains animations");
if (json && !json->animations.empty()) {
expect(plugin.setAnimation(json->animations.front().name, true, error),
std::string(line) + " animation can be selected: " + error);
const auto first = plugin.updatePreview(1.0F / 60.0F, error);
expect(first && !first->empty(), std::string(line) + " preview draws: " + error);
const auto later = plugin.updatePreview(0.25F, error);
expect(later && !later->empty(), std::string(line) + " later frame draws: " + error);
}
error.clear();
const auto binary = plugin.loadSkeleton(binaryPath, atlasPath, 1.0F, error);
expect(binary.has_value(), std::string("official ") + line + " SKEL loads: " + error);
}
void testLegacyOfficialAssets() {
#if defined(DTS_TEST_38_JSON)
testLegacyOfficialAsset("3.8", DTS_RUNTIME_38_PATH,
DTS_TEST_38_JSON, DTS_TEST_38_SKEL, DTS_TEST_38_ATLAS);
testLegacyOfficialAsset("4.0", DTS_RUNTIME_40_PATH,
DTS_TEST_40_JSON, DTS_TEST_40_SKEL, DTS_TEST_40_ATLAS);
testLegacyOfficialAsset("4.1", DTS_RUNTIME_41_PATH,
DTS_TEST_41_JSON, DTS_TEST_41_SKEL, DTS_TEST_41_ATLAS);
#endif
}
} // namespace
int main() {
testVersions();
testLauncherOutput();
testTimedOutOutput();
testMacBundleNormalization();
testProcessTimeout();
testDataVersionDetection();
testAtlasParsing();
testInstalledVersionDiscovery();
testRuntimePlugins();
testLegacyOfficialAssets();
testRuntime42OfficialAssets();
testRuntime43OfficialAssets();
if (failures == 0) {
std::cout << "All core tests passed.\n";
}
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}
Submodule DateToSpine/third_party/spine-runtimes/3.8 added at 8b4844bd4b
Submodule DateToSpine/third_party/spine-runtimes/4.0 added at 425ce416bb
Submodule DateToSpine/third_party/spine-runtimes/4.1 added at 77a5db0ec6
Submodule DateToSpine/third_party/spine-runtimes/4.2 added at e7dc1435fa
Submodule DateToSpine/third_party/spine-runtimes/4.3 added at 4309c05c28