308 lines
11 KiB
C++
308 lines
11 KiB
C++
#include "dialogs/FrameAnimationDialog.h"
|
||
|
||
#include "core/animation/AnimationSampling.h"
|
||
#include "core/workspace/ProjectWorkspace.h"
|
||
|
||
#include <QBoxLayout>
|
||
#include <QDir>
|
||
#include <QFileDialog>
|
||
#include <QFileInfo>
|
||
#include <QImage>
|
||
#include <QImageReader>
|
||
#include <QLabel>
|
||
#include <QListWidget>
|
||
#include <QMessageBox>
|
||
#include <QPixmap>
|
||
#include <QPushButton>
|
||
|
||
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<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();
|
||
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<int>(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<int>(files.size()));
|
||
for (int i = 0; i < count; ++i) {
|
||
applyImageToFrame(m_start + i, d.filePath(files[i]));
|
||
}
|
||
rebuildFrameList();
|
||
onSelectFrame();
|
||
}
|
||
|