This commit is contained in:
2026-05-14 13:30:06 +08:00
parent 974946cee4
commit e43171521d
91 changed files with 10485 additions and 3254 deletions
+358 -53
View File
@@ -1,29 +1,45 @@
#include "dialogs/FrameAnimationDialog.h"
#include "core/animation/AnimationSampling.h"
#include "core/image/ImageDecodeConfig.h"
#include "core/image/ImageFileLoader.h"
#include "core/net/ModelServerClient.h"
#include "core/workspace/ProjectWorkspace.h"
#include "dialogs/CancelableTaskDialog.h"
#include <QBoxLayout>
#include <QAbstractItemView>
#include <QBuffer>
#include <QColor>
#include <QColorDialog>
#include <QDialog>
#include <QDialogButtonBox>
#include <QDir>
#include <QFormLayout>
#include <QFileDialog>
#include <QFileInfo>
#include <QImage>
#include <QImageReader>
#include <QIODevice>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QMessageBox>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QPixmap>
#include <QPushButton>
#include <QUrl>
namespace {
QString resolvedImageAbsForFrame(const core::ProjectWorkspace& ws,
const core::Project::Entity& e,
int frame) {
const QString rel = core::sampleImagePath(e.imageFrames, frame, e.imagePath);
if (rel.isEmpty()) return {};
const QString abs = QDir(ws.projectDir()).filePath(rel);
return abs;
const QByteArray png = ws.entityImagePngAt(e.id, frame);
if (png.isEmpty()) return {};
return QStringLiteral("pngb64:") + QString::fromLatin1(png.toBase64());
}
} // namespace
@@ -56,6 +72,7 @@ FrameAnimationDialog::FrameAnimationDialog(core::ProjectWorkspace& workspace,
m_list = new QListWidget(this);
m_list->setMinimumWidth(240);
m_list->setSelectionMode(QAbstractItemView::ExtendedSelection);
mid->addWidget(m_list, 0);
auto* right = new QVBoxLayout();
@@ -71,18 +88,27 @@ FrameAnimationDialog::FrameAnimationDialog(core::ProjectWorkspace& workspace,
auto* row = new QHBoxLayout();
right->addLayout(row);
m_btnReplace = new QPushButton(QStringLiteral("替换此帧…"), this);
m_btnClear = new QPushButton(QStringLiteral("清除此帧(恢复默认)"), this);
m_btnClear = new QPushButton(QStringLiteral("清除选中帧"), this);
row->addWidget(m_btnReplace);
row->addWidget(m_btnClear);
auto* row2 = new QHBoxLayout();
right->addLayout(row2);
m_btnImportFiles = new QPushButton(QStringLiteral("批量导入(多选图片"), this);
m_btnImportFolder = new QPushButton(QStringLiteral("批量导入文件夹"), this);
m_btnImportFiles = new QPushButton(QStringLiteral("导入图片…"), this);
m_btnImportFolder = new QPushButton(QStringLiteral("导入文件夹…"), this);
row2->addWidget(m_btnImportFiles);
row2->addWidget(m_btnImportFolder);
row2->addStretch(1);
auto* row3 = new QHBoxLayout();
right->addLayout(row3);
m_btnAiGenerate = new QPushButton(QStringLiteral("AI 生成逐帧动画…"), this);
row3->addWidget(m_btnAiGenerate);
auto* aiHint = new QLabel(QStringLiteral("写入选中区间"), this);
aiHint->setStyleSheet(QStringLiteral("color: #777;"));
row3->addWidget(aiHint);
row3->addStretch(1);
auto* closeRow = new QHBoxLayout();
root->addLayout(closeRow);
closeRow->addStretch(1);
@@ -95,6 +121,7 @@ FrameAnimationDialog::FrameAnimationDialog(core::ProjectWorkspace& workspace,
connect(m_btnClear, &QPushButton::clicked, this, &FrameAnimationDialog::onClearCurrentFrame);
connect(m_btnImportFiles, &QPushButton::clicked, this, &FrameAnimationDialog::onBatchImportFiles);
connect(m_btnImportFolder, &QPushButton::clicked, this, &FrameAnimationDialog::onBatchImportFolder);
connect(m_btnAiGenerate, &QPushButton::clicked, this, &FrameAnimationDialog::onGenerateAiFrames);
rebuildFrameList();
if (m_list->count() > 0) {
@@ -118,16 +145,11 @@ void FrameAnimationDialog::rebuildFrameList() {
// 默认贴图(用于 UI 提示)
m_defaultImageAbs.clear();
if (!hit->imagePath.isEmpty()) {
const QString abs = QDir(m_workspace.projectDir()).filePath(hit->imagePath);
if (QFileInfo::exists(abs)) {
m_defaultImageAbs = abs;
}
}
for (int f = m_start; f <= m_end; ++f) {
bool hasCustom = false;
for (const auto& k : hit->imageFrames) {
const auto spriteFrames = m_workspace.entitySpriteFrames(m_entityId);
for (const auto& k : spriteFrames) {
if (k.frame == f) {
hasCustom = true;
break;
@@ -159,38 +181,33 @@ void FrameAnimationDialog::updatePreviewForFrame(int frame) {
if (!hit) return;
const QString abs = resolvedImageAbsForFrame(m_workspace, *hit, frame);
if (abs.isEmpty() || !QFileInfo::exists(abs)) {
if (abs.isEmpty()) {
m_preview->setText(QStringLiteral("无图像"));
return;
}
QPixmap pm(abs);
if (pm.isNull()) {
QImage loaded;
if (abs.startsWith(QStringLiteral("pngb64:"))) {
const QByteArray b64 = abs.mid(QStringLiteral("pngb64:").size()).toLatin1();
const QByteArray raw = QByteArray::fromBase64(b64);
loaded.loadFromData(raw, "PNG");
} else {
if (!QFileInfo::exists(abs)) {
m_preview->setText(QStringLiteral("无图像"));
return;
}
loaded = core::image_file::loadImageLimited(abs, core::image_decode::kPreviewMaxPixels);
}
if (loaded.isNull()) {
m_preview->setText(QStringLiteral("加载失败"));
return;
}
QPixmap pm = QPixmap::fromImage(loaded);
m_preview->setPixmap(pm.scaled(m_preview->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
}
bool FrameAnimationDialog::applyImageToFrame(int frame, const QString& absImagePath) {
// Qt 默认的 image allocation limit 较小,超大分辨率图可能会被拒绝。
// 这里提高 limit,并对极端大图按像素数上限自动缩放,避免 OOM。
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
QImageReader::setAllocationLimit(1024); // MB
#endif
QImageReader reader(absImagePath);
reader.setAutoTransform(true);
const QSize sz = reader.size();
if (sz.isValid()) {
constexpr qint64 kMaxPixels = 120LL * 1000LL * 1000LL; // 120MP
const qint64 pixels = qint64(sz.width()) * qint64(sz.height());
if (pixels > kMaxPixels) {
const double s = std::sqrt(double(kMaxPixels) / std::max<double>(1.0, double(pixels)));
const int nw = std::max(1, int(std::lround(sz.width() * s)));
const int nh = std::max(1, int(std::lround(sz.height() * s)));
reader.setScaledSize(QSize(nw, nh));
}
}
QImage img = reader.read();
QImage img =
core::image_file::loadImageLimited(absImagePath, core::image_decode::kWorkspaceMaxPixels);
if (img.isNull()) {
return false;
}
@@ -200,6 +217,93 @@ bool FrameAnimationDialog::applyImageToFrame(int frame, const QString& absImageP
return m_workspace.setEntityImageFrame(m_entityId, frame, img);
}
QVector<int> FrameAnimationDialog::targetFramesForWrite(int requestedCount) const {
QVector<int> frames;
if (m_list) {
const auto selected = m_list->selectedItems();
frames.reserve(selected.size());
for (auto* it : selected) {
if (it) frames.push_back(it->data(Qt::UserRole).toInt());
}
std::sort(frames.begin(), frames.end());
frames.erase(std::unique(frames.begin(), frames.end()), frames.end());
}
if (!frames.isEmpty()) {
if (requestedCount > 0 && frames.size() > requestedCount) {
frames.resize(requestedCount);
}
return frames;
}
const int count = requestedCount > 0 ? std::min(requestedCount, m_end - m_start + 1)
: (m_end - m_start + 1);
frames.reserve(count);
for (int i = 0; i < count; ++i) {
frames.push_back(m_start + i);
}
return frames;
}
QByteArray FrameAnimationDialog::currentEntityPngBytes(QString* outError) const {
if (outError) outError->clear();
if (!m_workspace.isOpen()) {
if (outError) *outError = QStringLiteral("未打开项目。");
return {};
}
const auto& ents = m_workspace.entities();
const core::Project::Entity* hit = nullptr;
for (const auto& e : ents) {
if (e.id == m_entityId) {
hit = &e;
break;
}
}
if (!hit) {
if (outError) *outError = QStringLiteral("未找到当前实体。");
return {};
}
int sourceFrame = m_start;
if (m_list && m_list->currentItem()) {
sourceFrame = m_list->currentItem()->data(Qt::UserRole).toInt();
}
const QString abs = resolvedImageAbsForFrame(m_workspace, *hit, sourceFrame);
if (abs.isEmpty()) {
if (outError) *outError = QStringLiteral("当前实体没有可用的角色贴图。");
return {};
}
QImage img;
if (abs.startsWith(QStringLiteral("pngb64:"))) {
const QByteArray b64 = abs.mid(QStringLiteral("pngb64:").size()).toLatin1();
const QByteArray raw = QByteArray::fromBase64(b64);
img.loadFromData(raw, "PNG");
} else {
if (!QFileInfo::exists(abs)) {
if (outError) *outError = QStringLiteral("当前实体没有可用的角色贴图。");
return {};
}
img = core::image_file::loadImageLimited(abs, core::image_decode::kWorkspaceMaxPixels);
}
if (img.isNull()) {
if (outError) *outError = QStringLiteral("角色贴图读取失败。");
return {};
}
if (img.format() != QImage::Format_ARGB32 && img.format() != QImage::Format_ARGB32_Premultiplied) {
img = img.convertToFormat(QImage::Format_ARGB32);
}
QByteArray png;
QBuffer buf(&png);
if (!buf.open(QIODevice::WriteOnly) || !img.save(&buf, "PNG")) {
if (outError) *outError = QStringLiteral("角色贴图编码 PNG 失败。");
return {};
}
return png;
}
void FrameAnimationDialog::onReplaceCurrentFrame() {
auto* it = m_list->currentItem();
if (!it) return;
@@ -222,15 +326,17 @@ void FrameAnimationDialog::onReplaceCurrentFrame() {
}
if (hit) {
// 是否已有精确关键帧
for (const auto& k : hit->imageFrames) {
const auto spriteFrames = m_workspace.entitySpriteFrames(m_entityId);
for (const auto& k : spriteFrames) {
if (k.frame == f + 1) {
hasExplicitKeyAtFPlus1 = true;
}
}
prevRelPathForF = core::sampleImagePath(hit->imageFrames, f, hit->imagePath);
prevRelPathForFPlus1 = core::sampleImagePath(hit->imageFrames, f + 1, hit->imagePath);
prevRelPathForF = m_workspace.entityImagePathAt(m_entityId, f);
prevRelPathForFPlus1 = m_workspace.entityImagePathAt(m_entityId, f + 1);
}
}
(void)prevRelPathForF;
const QString path = QFileDialog::getOpenFileName(
this,
@@ -255,14 +361,12 @@ void FrameAnimationDialog::onReplaceCurrentFrame() {
}
void FrameAnimationDialog::onClearCurrentFrame() {
auto* it = m_list->currentItem();
if (!it) return;
const int f = it->data(Qt::UserRole).toInt();
if (!m_workspace.removeEntityImageFrame(m_entityId, f)) {
return;
const QVector<int> frames = targetFramesForWrite(0);
for (int f : frames) {
m_workspace.removeEntityImageFrame(m_entityId, f);
}
rebuildFrameList();
updatePreviewForFrame(f);
onSelectFrame();
}
void FrameAnimationDialog::onBatchImportFiles() {
@@ -274,10 +378,10 @@ void FrameAnimationDialog::onBatchImportFiles() {
if (paths.isEmpty()) return;
QStringList sorted = paths;
sorted.sort(Qt::CaseInsensitive);
const int need = m_end - m_start + 1;
const int count = std::min(need, static_cast<int>(sorted.size()));
const QVector<int> targets = targetFramesForWrite(static_cast<int>(sorted.size()));
const int count = std::min(static_cast<int>(targets.size()), static_cast<int>(sorted.size()));
for (int i = 0; i < count; ++i) {
applyImageToFrame(m_start + i, sorted[i]);
applyImageToFrame(targets[i], sorted[i]);
}
rebuildFrameList();
onSelectFrame();
@@ -296,12 +400,213 @@ void FrameAnimationDialog::onBatchImportFolder() {
QStringLiteral("*.webp")};
const QStringList files = d.entryList(filters, QDir::Files, QDir::Name);
if (files.isEmpty()) return;
const int need = m_end - m_start + 1;
const int count = std::min(need, static_cast<int>(files.size()));
const QVector<int> targets = targetFramesForWrite(static_cast<int>(files.size()));
const int count = std::min(static_cast<int>(targets.size()), static_cast<int>(files.size()));
for (int i = 0; i < count; ++i) {
applyImageToFrame(m_start + i, d.filePath(files[i]));
applyImageToFrame(targets[i], d.filePath(files[i]));
}
rebuildFrameList();
onSelectFrame();
}
void FrameAnimationDialog::onGenerateAiFrames() {
const QVector<int> targetFrames = targetFramesForWrite(0);
const int frameCount = static_cast<int>(targetFrames.size());
if (frameCount <= 0) {
QMessageBox::warning(this, QStringLiteral("AI 生成动画"), QStringLiteral("当前区间无有效帧。"));
return;
}
QString imageErr;
const QByteArray characterPng = currentEntityPngBytes(&imageErr);
if (characterPng.isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("AI 生成动画"),
imageErr.isEmpty() ? QStringLiteral("无法读取当前实体贴图。") : imageErr);
return;
}
QDialog cfg(this);
cfg.setWindowTitle(QStringLiteral("AI 生成实体动画"));
cfg.setModal(true);
auto* root = new QVBoxLayout(&cfg);
root->setContentsMargins(14, 14, 14, 14);
root->setSpacing(10);
auto* form = new QFormLayout();
root->addLayout(form);
auto* promptEdit = new QLineEdit(QStringLiteral("walk cycle"), &cfg);
form->addRow(QStringLiteral("动画提示词"), promptEdit);
auto* bgRow = new QWidget(&cfg);
auto* bgLayout = new QHBoxLayout(bgRow);
bgLayout->setContentsMargins(0, 0, 0, 0);
auto* bgEdit = new QLineEdit(QStringLiteral("#00FF00"), bgRow);
auto* bgBtn = new QPushButton(QStringLiteral("选择…"), bgRow);
bgLayout->addWidget(bgEdit, 1);
bgLayout->addWidget(bgBtn);
form->addRow(QStringLiteral("生成背景色"), bgRow);
auto* frameLabel = new QLabel(QString::number(frameCount), &cfg);
form->addRow(QStringLiteral("帧数"), frameLabel);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &cfg);
buttons->button(QDialogButtonBox::Ok)->setText(QStringLiteral("开始生成"));
buttons->button(QDialogButtonBox::Cancel)->setText(QStringLiteral("取消"));
root->addWidget(buttons);
connect(bgBtn, &QPushButton::clicked, &cfg, [bgEdit, &cfg]() {
const QColor initial(bgEdit->text().trimmed());
const QColor c = QColorDialog::getColor(initial.isValid() ? initial : QColor(0, 255, 0),
&cfg,
QStringLiteral("选择纯色背景"));
if (c.isValid()) {
bgEdit->setText(c.name(QColor::HexRgb).toUpper());
}
});
connect(buttons, &QDialogButtonBox::accepted, &cfg, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, &cfg, &QDialog::reject);
if (cfg.exec() != QDialog::Accepted) {
return;
}
const QString prompt = promptEdit->text().trimmed();
if (prompt.isEmpty()) {
QMessageBox::warning(this, QStringLiteral("AI 生成动画"), QStringLiteral("动画提示词不能为空。"));
return;
}
QString base;
const QByteArray env = qgetenv("MODEL_SERVER_URL");
base = env.isEmpty() ? QStringLiteral("http://127.0.0.1:8000") : QString::fromUtf8(env);
auto* client = new core::ModelServerClient(this);
client->setBaseUrl(QUrl(base));
QString immediateErr;
QNetworkReply* reply = client->animateCharacterSequenceAsync(
characterPng,
prompt,
QStringLiteral("blur, distortion, extra limbs, bad hands, text, watermark"),
bgEdit->text(),
40,
frameCount,
25,
8.0,
768,
-1,
&immediateErr);
if (!reply) {
QMessageBox::warning(this,
QStringLiteral("AI 生成动画"),
immediateErr.isEmpty() ? QStringLiteral("无法发起后端请求。") : immediateErr);
client->deleteLater();
return;
}
auto* task = new CancelableTaskDialog(
QStringLiteral("AI 生成实体动画"),
QStringLiteral("正在生成 PNG 序列。该过程可能耗时较久,请稍候……"),
this);
task->setAttribute(Qt::WA_DeleteOnClose, true);
connect(task, &CancelableTaskDialog::canceled, this, [reply, task]() {
if (reply) reply->abort();
if (task) task->reject();
});
connect(reply, &QNetworkReply::finished, this, [this, reply, task, client, frameCount, targetFrames]() {
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray raw = reply->readAll();
const auto netErr = reply->error();
const QString netErrStr = reply->errorString();
reply->deleteLater();
client->deleteLater();
if (task) task->close();
if (netErr != QNetworkReply::NoError) {
QMessageBox::warning(this,
QStringLiteral("AI 生成动画"),
QStringLiteral("网络错误:%1").arg(netErrStr));
return;
}
if (httpStatus != 200) {
QString detail;
const QJsonDocument jerr = QJsonDocument::fromJson(raw);
if (jerr.isObject()) {
detail = jerr.object().value(QStringLiteral("detail")).toString();
}
QMessageBox::warning(this,
QStringLiteral("AI 生成动画"),
detail.isEmpty() ? QStringLiteral("后端返回 HTTP %1。").arg(httpStatus)
: QStringLiteral("后端错误(HTTP %1):%2").arg(httpStatus).arg(detail));
return;
}
const QJsonDocument jd = QJsonDocument::fromJson(raw);
if (!jd.isObject()) {
QMessageBox::warning(this, QStringLiteral("AI 生成动画"), QStringLiteral("响应不是 JSON。"));
return;
}
const QJsonObject obj = jd.object();
if (!obj.value(QStringLiteral("success")).toBool()) {
const QString err = obj.value(QStringLiteral("error")).toString();
QMessageBox::warning(this,
QStringLiteral("AI 生成动画"),
err.isEmpty() ? QStringLiteral("生成失败。") : err);
return;
}
const QJsonArray frames = obj.value(QStringLiteral("frames")).toArray();
if (frames.isEmpty()) {
QMessageBox::warning(this, QStringLiteral("AI 生成动画"), QStringLiteral("后端未返回 PNG 帧。"));
return;
}
int applied = 0;
const int count = std::min(frameCount, static_cast<int>(frames.size()));
for (int i = 0; i < count; ++i) {
const QJsonObject fobj = frames.at(i).toObject();
const QString b64 = fobj.value(QStringLiteral("image_b64")).toString();
if (b64.isEmpty()) {
continue;
}
QImage img;
img.loadFromData(QByteArray::fromBase64(b64.toLatin1()), "PNG");
if (img.isNull()) {
continue;
}
if (img.format() != QImage::Format_ARGB32_Premultiplied) {
img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
if (i < targetFrames.size() && m_workspace.setEntityImageFrame(m_entityId, targetFrames[i], img)) {
++applied;
}
}
rebuildFrameList();
if (m_list && m_list->count() > 0) {
m_list->setCurrentRow(0);
}
onSelectFrame();
if (applied <= 0) {
QMessageBox::warning(this, QStringLiteral("AI 生成动画"), QStringLiteral("生成成功,但写入项目失败。"));
return;
}
if (applied < frameCount) {
QMessageBox::information(this,
QStringLiteral("AI 生成动画"),
QStringLiteral("已写入 %1/%2 帧;部分帧解析或保存失败。").arg(applied).arg(frameCount));
} else {
QMessageBox::information(this,
QStringLiteral("AI 生成动画"),
QStringLiteral("已写入 %1 帧。").arg(applied));
}
});
task->show();
}