613 lines
22 KiB
C++
613 lines
22 KiB
C++
#include "dialogs/FrameAnimationDialog.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 <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 QByteArray png = ws.entityImagePngAt(e.id, frame);
|
|
if (png.isEmpty()) return {};
|
|
return QStringLiteral("pngb64:") + QString::fromLatin1(png.toBase64());
|
|
}
|
|
|
|
} // namespace
|
|
|
|
FrameAnimationDialog::FrameAnimationDialog(core::ProjectWorkspace& workspace,
|
|
const QString& entityId,
|
|
int startFrame,
|
|
int endFrame,
|
|
QWidget* parent)
|
|
: QDialog(parent)
|
|
, m_workspace(workspace)
|
|
, m_entityId(entityId) {
|
|
setWindowTitle(QStringLiteral("区间动画帧"));
|
|
setModal(true);
|
|
setMinimumSize(720, 420);
|
|
|
|
m_start = std::min(startFrame, endFrame);
|
|
m_end = std::max(startFrame, endFrame);
|
|
|
|
auto* root = new QVBoxLayout(this);
|
|
root->setContentsMargins(12, 12, 12, 12);
|
|
root->setSpacing(10);
|
|
|
|
m_title = new QLabel(this);
|
|
m_title->setText(QStringLiteral("实体 %1 | 区间 [%2, %3]").arg(m_entityId).arg(m_start).arg(m_end));
|
|
root->addWidget(m_title);
|
|
|
|
auto* mid = new QHBoxLayout();
|
|
root->addLayout(mid, 1);
|
|
|
|
m_list = new QListWidget(this);
|
|
m_list->setMinimumWidth(240);
|
|
m_list->setSelectionMode(QAbstractItemView::ExtendedSelection);
|
|
mid->addWidget(m_list, 0);
|
|
|
|
auto* right = new QVBoxLayout();
|
|
mid->addLayout(right, 1);
|
|
|
|
m_preview = new QLabel(this);
|
|
m_preview->setMinimumSize(320, 240);
|
|
m_preview->setFrameShape(QFrame::StyledPanel);
|
|
m_preview->setAlignment(Qt::AlignCenter);
|
|
m_preview->setText(QStringLiteral("选择一帧"));
|
|
right->addWidget(m_preview, 1);
|
|
|
|
auto* row = new QHBoxLayout();
|
|
right->addLayout(row);
|
|
m_btnReplace = 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);
|
|
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);
|
|
auto* btnClose = new QPushButton(QStringLiteral("关闭"), this);
|
|
closeRow->addWidget(btnClose);
|
|
|
|
connect(btnClose, &QPushButton::clicked, this, &QDialog::accept);
|
|
connect(m_list, &QListWidget::currentRowChanged, this, [this](int) { onSelectFrame(); });
|
|
connect(m_btnReplace, &QPushButton::clicked, this, &FrameAnimationDialog::onReplaceCurrentFrame);
|
|
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) {
|
|
m_list->setCurrentRow(0);
|
|
}
|
|
}
|
|
|
|
void FrameAnimationDialog::rebuildFrameList() {
|
|
m_list->clear();
|
|
if (!m_workspace.isOpen()) 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) return;
|
|
|
|
// 默认贴图(用于 UI 提示)
|
|
m_defaultImageAbs.clear();
|
|
|
|
for (int f = m_start; f <= m_end; ++f) {
|
|
bool hasCustom = false;
|
|
const auto spriteFrames = m_workspace.entitySpriteFrames(m_entityId);
|
|
for (const auto& k : spriteFrames) {
|
|
if (k.frame == f) {
|
|
hasCustom = true;
|
|
break;
|
|
}
|
|
}
|
|
auto* it = new QListWidgetItem(QStringLiteral("%1%2").arg(f).arg(hasCustom ? QStringLiteral(" *") : QString()));
|
|
it->setData(Qt::UserRole, f);
|
|
m_list->addItem(it);
|
|
}
|
|
}
|
|
|
|
void FrameAnimationDialog::onSelectFrame() {
|
|
auto* it = m_list->currentItem();
|
|
if (!it) return;
|
|
const int f = it->data(Qt::UserRole).toInt();
|
|
updatePreviewForFrame(f);
|
|
}
|
|
|
|
void FrameAnimationDialog::updatePreviewForFrame(int frame) {
|
|
if (!m_workspace.isOpen()) 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) return;
|
|
|
|
const QString abs = resolvedImageAbsForFrame(m_workspace, *hit, frame);
|
|
if (abs.isEmpty()) {
|
|
m_preview->setText(QStringLiteral("无图像"));
|
|
return;
|
|
}
|
|
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) {
|
|
QImage img =
|
|
core::image_file::loadImageLimited(absImagePath, core::image_decode::kWorkspaceMaxPixels);
|
|
if (img.isNull()) {
|
|
return false;
|
|
}
|
|
if (img.format() != QImage::Format_ARGB32_Premultiplied) {
|
|
img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied);
|
|
}
|
|
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;
|
|
const int f = it->data(Qt::UserRole).toInt();
|
|
|
|
// 在真正替换前先记录当前帧/下一帧的旧图像来源,用于“只影响当前帧”:
|
|
// 对于原本只在区间端点设置了关键帧、使用 Hold 采样的情况,
|
|
// 若直接改写关键帧会导致后续所有帧都跟着换图,这里通过在 f+1 上补一帧旧图像来“切断”区间。
|
|
QString prevRelPathForF;
|
|
QString prevRelPathForFPlus1;
|
|
bool hasExplicitKeyAtFPlus1 = false;
|
|
if (m_workspace.isOpen()) {
|
|
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) {
|
|
// 是否已有精确关键帧
|
|
const auto spriteFrames = m_workspace.entitySpriteFrames(m_entityId);
|
|
for (const auto& k : spriteFrames) {
|
|
if (k.frame == f + 1) {
|
|
hasExplicitKeyAtFPlus1 = true;
|
|
}
|
|
}
|
|
prevRelPathForF = m_workspace.entityImagePathAt(m_entityId, f);
|
|
prevRelPathForFPlus1 = m_workspace.entityImagePathAt(m_entityId, f + 1);
|
|
}
|
|
}
|
|
(void)prevRelPathForF;
|
|
|
|
const QString path = QFileDialog::getOpenFileName(
|
|
this,
|
|
QStringLiteral("选择该帧图像"),
|
|
QString(),
|
|
QStringLiteral("Images (*.png *.jpg *.jpeg *.bmp *.webp);;All Files (*)"));
|
|
if (path.isEmpty()) return;
|
|
if (!applyImageToFrame(f, path)) {
|
|
QMessageBox::warning(this, QStringLiteral("动画帧"), QStringLiteral("写入该帧失败。"));
|
|
return;
|
|
}
|
|
|
|
// “单帧替换”的强语义:无论 f 原本是否是关键帧,都不应影响 f+1 之后的帧。
|
|
// 因此在 f+1 上补一个“替换前 f+1 使用的来源”,以切断 Hold 区间(不覆盖已有关键帧)。
|
|
// 性能:这里直接写入相对路径,不读图不写盘。
|
|
if (!hasExplicitKeyAtFPlus1 && !prevRelPathForFPlus1.isEmpty()) {
|
|
m_workspace.setEntityImageFramePath(m_entityId, f + 1, prevRelPathForFPlus1);
|
|
}
|
|
|
|
rebuildFrameList();
|
|
updatePreviewForFrame(f);
|
|
}
|
|
|
|
void FrameAnimationDialog::onClearCurrentFrame() {
|
|
const QVector<int> frames = targetFramesForWrite(0);
|
|
for (int f : frames) {
|
|
m_workspace.removeEntityImageFrame(m_entityId, f);
|
|
}
|
|
rebuildFrameList();
|
|
onSelectFrame();
|
|
}
|
|
|
|
void FrameAnimationDialog::onBatchImportFiles() {
|
|
const QStringList paths = QFileDialog::getOpenFileNames(
|
|
this,
|
|
QStringLiteral("选择逐帧动画图片(按文件名排序)"),
|
|
QString(),
|
|
QStringLiteral("Images (*.png *.jpg *.jpeg *.bmp *.webp);;All Files (*)"));
|
|
if (paths.isEmpty()) return;
|
|
QStringList sorted = paths;
|
|
sorted.sort(Qt::CaseInsensitive);
|
|
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(targets[i], sorted[i]);
|
|
}
|
|
rebuildFrameList();
|
|
onSelectFrame();
|
|
}
|
|
|
|
void FrameAnimationDialog::onBatchImportFolder() {
|
|
const QString dir = QFileDialog::getExistingDirectory(this, QStringLiteral("选择逐帧动画图片文件夹"));
|
|
if (dir.isEmpty()) return;
|
|
QDir d(dir);
|
|
d.setFilter(QDir::Files | QDir::Readable);
|
|
d.setSorting(QDir::Name);
|
|
const QStringList filters = {QStringLiteral("*.png"),
|
|
QStringLiteral("*.jpg"),
|
|
QStringLiteral("*.jpeg"),
|
|
QStringLiteral("*.bmp"),
|
|
QStringLiteral("*.webp")};
|
|
const QStringList files = d.entryList(filters, QDir::Files, QDir::Name);
|
|
if (files.isEmpty()) return;
|
|
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(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();
|
|
}
|
|
|