#include "dialogs/FrameAnimationDialog.h" #include "core/animation/AnimationSampling.h" #include "core/workspace/ProjectWorkspace.h" #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 QString rel = core::sampleImagePath(e.imageFrames, frame, e.imagePath); if (rel.isEmpty()) return {}; const QString abs = QDir(ws.projectDir()).filePath(rel); return abs; } } // 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); 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* 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); 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(); 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) { 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() || !QFileInfo::exists(abs)) { m_preview->setText(QStringLiteral("无图像")); return; } QPixmap pm(abs); if (pm.isNull()) { m_preview->setText(QStringLiteral("加载失败")); return; } 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(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(); 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); } 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) { // 是否已有精确关键帧 for (const auto& k : hit->imageFrames) { if (k.frame == f + 1) { hasExplicitKeyAtFPlus1 = true; } } prevRelPathForF = core::sampleImagePath(hit->imageFrames, f, hit->imagePath); prevRelPathForFPlus1 = core::sampleImagePath(hit->imageFrames, f + 1, hit->imagePath); } } 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() { auto* it = m_list->currentItem(); if (!it) return; const int f = it->data(Qt::UserRole).toInt(); if (!m_workspace.removeEntityImageFrame(m_entityId, f)) { return; } rebuildFrameList(); updatePreviewForFrame(f); } 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 int need = m_end - m_start + 1; const int count = std::min(need, static_cast(sorted.size())); for (int i = 0; i < count; ++i) { applyImageToFrame(m_start + 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 int need = m_end - m_start + 1; const int count = std::min(need, static_cast(files.size())); for (int i = 0; i < count; ++i) { applyImageToFrame(m_start + i, d.filePath(files[i])); } rebuildFrameList(); onSelectFrame(); }