导出数据转spine
This commit is contained in:
1 parent
d258d4f435
commit
3a7c0b4092
65 files changed
+6000
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user