导出数据转spine

This commit is contained in:
tianmo committed 2026-09-07 21:29:15 +08:00
1 parent d258d4f435
commit 3a7c0b4092
65 files changed
+6000

No files matched your search

+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