#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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 FrameAnimationDialog::targetFramesForWrite(int requestedCount) const { QVector 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 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 targets = targetFramesForWrite(static_cast(sorted.size())); const int count = std::min(static_cast(targets.size()), static_cast(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 targets = targetFramesForWrite(static_cast(files.size())); const int count = std::min(static_cast(targets.size()), static_cast(files.size())); for (int i = 0; i < count; ++i) { applyImageToFrame(targets[i], d.filePath(files[i])); } rebuildFrameList(); onSelectFrame(); } void FrameAnimationDialog::onGenerateAiFrames() { const QVector targetFrames = targetFramesForWrite(0); const int frameCount = static_cast(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(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(); }