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
@@ -0,0 +1,90 @@
#include "dialogs/AnimationSchemeSettingsDialog.h"
#include "core/domain/Project.h"
#include "core/workspace/ProjectWorkspace.h"
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QSpinBox>
#include <QVBoxLayout>
#include <QCheckBox>
AnimationSchemeSettingsDialog::AnimationSchemeSettingsDialog(Mode mode, core::ProjectWorkspace& workspace,
QWidget* parent)
: QDialog(parent)
, m_mode(mode)
, m_workspace(workspace) {
setModal(true);
setMinimumWidth(360);
if (mode == Mode::Create) {
setWindowTitle(QStringLiteral("新建动画方案"));
} else {
setWindowTitle(QStringLiteral("动画方案设置"));
}
auto* root = new QVBoxLayout(this);
root->setContentsMargins(12, 12, 12, 12);
auto* form = new QFormLayout();
form->setSpacing(8);
root->addLayout(form);
m_name = new QLineEdit(this);
m_name->setPlaceholderText(QStringLiteral("显示名称"));
form->addRow(QStringLiteral("名称"), m_name);
m_frames = new QSpinBox(this);
m_frames->setRange(1, 1000000);
m_frames->setValue(600);
form->addRow(QStringLiteral("总帧数"), m_frames);
m_fps = new QSpinBox(this);
m_fps->setRange(1, 240);
m_fps->setValue(std::max(1, m_workspace.project().fps()));
form->addRow(QStringLiteral("帧率"), m_fps);
m_loop = new QCheckBox(QStringLiteral("循环播放"), this);
m_loop->setChecked(true);
form->addRow(QString(), m_loop);
auto* box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
root->addWidget(box);
connect(box, &QDialogButtonBox::accepted, this, &AnimationSchemeSettingsDialog::accept);
connect(box, &QDialogButtonBox::rejected, this, &QDialog::reject);
}
void AnimationSchemeSettingsDialog::setEditTarget(const QString& animationId) {
m_editId = animationId;
if (const core::Project::Animation* a = m_workspace.project().findAnimationById(animationId)) {
m_name->setText(a->name);
m_frames->setValue(std::max(1, a->lengthFrames));
m_fps->setValue(std::max(1, a->fps));
m_loop->setChecked(a->loop);
}
}
void AnimationSchemeSettingsDialog::accept() {
const QString nm = m_name->text().trimmed();
const int frames = m_frames->value();
const int fps = m_fps->value();
const bool loop = m_loop->isChecked();
if (m_mode == Mode::Create) {
if (!m_workspace.createAnimationScheme(nm, frames, loop, fps, &m_createdNewId)) {
QMessageBox::warning(this, windowTitle(), QStringLiteral("新建失败"));
return;
}
} else {
if (m_editId.isEmpty() || !m_workspace.project().findAnimationById(m_editId)) {
QMessageBox::warning(this, windowTitle(), QStringLiteral("无效的动画方案"));
return;
}
if (!m_workspace.updateAnimationSchemeSettings(m_editId, nm, frames, loop, fps)) {
QMessageBox::warning(this, windowTitle(), QStringLiteral("保存失败"));
return;
}
}
QDialog::accept();
}
@@ -0,0 +1,37 @@
#pragma once
#include <QDialog>
#include <QString>
namespace core {
class ProjectWorkspace;
}
class QCheckBox;
class QLineEdit;
class QSpinBox;
class AnimationSchemeSettingsDialog final : public QDialog {
Q_OBJECT
public:
enum class Mode { Create, Edit };
AnimationSchemeSettingsDialog(Mode mode, core::ProjectWorkspace& workspace, QWidget* parent = nullptr);
void setEditTarget(const QString& animationId);
QString createdNewId() const { return m_createdNewId; }
private:
void accept() override;
Mode m_mode = Mode::Create;
core::ProjectWorkspace& m_workspace;
QString m_editId;
QString m_createdNewId;
QLineEdit* m_name = nullptr;
QSpinBox* m_frames = nullptr;
QSpinBox* m_fps = nullptr;
QCheckBox* m_loop = nullptr;
};
+20 -50
View File
@@ -10,13 +10,13 @@
namespace {
QPushButton* makeAlgoButton(const QString& title, const QString& subtitle, QWidget* parent) {
QPushButton* makeAlgoButton(const QString& title, QWidget* parent) {
auto* btn = new QPushButton(parent);
btn->setCheckable(false);
btn->setCursor(Qt::PointingHandCursor);
btn->setMinimumHeight(86);
btn->setMinimumHeight(48);
btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
btn->setText(title + QStringLiteral("\n") + subtitle);
btn->setText(title);
btn->setStyleSheet(
"QPushButton { text-align: left; padding: 10px 12px; border: 1px solid palette(mid); border-radius: 8px; }"
"QPushButton:hover { border-color: palette(highlight); }");
@@ -29,8 +29,8 @@ BlackholeResolveDialog::BlackholeResolveDialog(const QString& blackholeName, QWi
: QDialog(parent),
m_blackholeName(blackholeName) {
setModal(true);
setMinimumSize(560, 420);
setWindowTitle(QStringLiteral("黑洞解决"));
setMinimumSize(400, 320);
setWindowTitle(QStringLiteral("修复 · %1").arg(m_blackholeName));
auto* root = new QVBoxLayout(this);
m_pages = new QStackedWidget(this);
@@ -51,25 +51,13 @@ void BlackholeResolveDialog::buildSelectPage() {
layout->setContentsMargins(8, 8, 8, 8);
layout->setSpacing(12);
auto* title = new QLabel(QStringLiteral("第 1 步:选择黑洞解决算法"), m_pageSelect);
auto* sub = new QLabel(QStringLiteral("当前黑洞:%1").arg(m_blackholeName), m_pageSelect);
title->setStyleSheet("font-size: 18px; font-weight: 600;");
sub->setStyleSheet("color: palette(mid);");
auto* title = new QLabel(QStringLiteral("方式"), m_pageSelect);
title->setStyleSheet("font-size: 16px; font-weight: 600;");
layout->addWidget(title);
layout->addWidget(sub);
auto* btnCopy = makeAlgoButton(
QStringLiteral("复制背景其他区域"),
QStringLiteral("进入画布拖动取样框,直观选择复制来源。"),
m_pageSelect);
auto* btnOriginal = makeAlgoButton(
QStringLiteral("使用原始背景"),
QStringLiteral("撤销黑洞显示,恢复抠图前背景区域。"),
m_pageSelect);
auto* btnModel = makeAlgoButton(
QStringLiteral("模型补全(SDXL Inpaint"),
QStringLiteral("输入提示词,自动补全缺失区域;可预览后再决定是否接受。"),
m_pageSelect);
auto* btnCopy = makeAlgoButton(QStringLiteral("复制背景"), m_pageSelect);
auto* btnOriginal = makeAlgoButton(QStringLiteral("原始背景"), m_pageSelect);
auto* btnModel = makeAlgoButton(QStringLiteral("模型补全"), m_pageSelect);
layout->addWidget(btnCopy);
layout->addWidget(btnOriginal);
layout->addWidget(btnModel);
@@ -100,12 +88,8 @@ void BlackholeResolveDialog::buildDetailPage() {
layout->setSpacing(10);
m_detailTitle = new QLabel(m_pageDetail);
m_detailTitle->setStyleSheet("font-size: 18px; font-weight: 600;");
m_detailHint = new QLabel(m_pageDetail);
m_detailHint->setWordWrap(true);
m_detailHint->setStyleSheet("color: palette(mid);");
m_detailTitle->setStyleSheet("font-size: 15px; font-weight: 600;");
layout->addWidget(m_detailTitle);
layout->addWidget(m_detailHint);
m_algoDetails = new QStackedWidget(m_pageDetail);
@@ -120,10 +104,7 @@ void BlackholeResolveDialog::buildDetailPage() {
auto* pLay = new QVBoxLayout(panel);
pLay->setSpacing(8);
auto* tip = new QLabel(
QStringLiteral("说明:点击“应用”后进入画布拖动模式。\n在画布中拖动青色取样框,松开鼠标即可将该区域复制到黑洞位置并自动移除黑洞。"),
panel);
tip->setWordWrap(true);
auto* tip = new QLabel(QStringLiteral("应用后在画布拖取样框。"), panel);
tip->setStyleSheet("color: palette(mid);");
pLay->addWidget(tip);
cLay->addWidget(panel);
@@ -135,17 +116,9 @@ void BlackholeResolveDialog::buildDetailPage() {
{
auto* oLay = new QVBoxLayout(m_originalDetail);
oLay->setSpacing(8);
auto* desc = new QLabel(
QStringLiteral("该方案不会改动背景像素文件,只会将黑洞切换为不显示,从而恢复原始背景区域。"),
m_originalDetail);
auto* desc = new QLabel(QStringLiteral("仅隐藏黑洞,不改动背景文件。"), m_originalDetail);
desc->setWordWrap(true);
auto* note = new QLabel(
QStringLiteral("适用场景:当前黑洞区域无需二次修补,只需恢复抠图前背景;应用后黑洞会自动移除。"),
m_originalDetail);
note->setWordWrap(true);
note->setStyleSheet("color: palette(mid);");
oLay->addWidget(desc);
oLay->addWidget(note);
oLay->addStretch(1);
}
@@ -161,8 +134,8 @@ void BlackholeResolveDialog::buildDetailPage() {
pLay->setSpacing(8);
m_promptEdit = new QPlainTextEdit(panel);
m_promptEdit->setPlainText(QStringLiteral("This is part of a Chinese painting; please complete the background for me, following the style of the other parts."));
m_promptEdit->setMinimumHeight(90);
m_promptEdit->setPlaceholderText(QStringLiteral("提示词(可选)"));
m_promptEdit->setMinimumHeight(72);
pLay->addWidget(m_promptEdit);
mLay->addWidget(panel);
@@ -175,8 +148,8 @@ void BlackholeResolveDialog::buildDetailPage() {
layout->addWidget(m_algoDetails, 1);
auto* btns = new QDialogButtonBox(m_pageDetail);
auto* btnBack = btns->addButton(QStringLiteral("上一步"), QDialogButtonBox::ActionRole);
auto* btnApply = btns->addButton(QStringLiteral("应用"), QDialogButtonBox::AcceptRole);
auto* btnBack = btns->addButton(QStringLiteral("返回"), QDialogButtonBox::ActionRole);
auto* btnApply = btns->addButton(QStringLiteral("确定"), QDialogButtonBox::AcceptRole);
auto* btnCancel = btns->addButton(QDialogButtonBox::Cancel);
connect(btnBack, &QPushButton::clicked, this, [this]() {
m_pages->setCurrentWidget(m_pageSelect);
@@ -191,16 +164,13 @@ void BlackholeResolveDialog::buildDetailPage() {
void BlackholeResolveDialog::enterAlgorithmPage(Algorithm algo) {
m_selectedAlgorithm = algo;
if (algo == Algorithm::CopyBackgroundRegion) {
m_detailTitle->setText(QStringLiteral("第 2 步:复制背景其他区域"));
m_detailHint->setText(QStringLiteral("准备进入画布拖动取样框模式。"));
m_detailTitle->setText(QStringLiteral("复制背景"));
m_algoDetails->setCurrentWidget(m_copyDetail);
} else if (algo == Algorithm::UseOriginalBackground) {
m_detailTitle->setText(QStringLiteral("第 2 步:使用原始背景"));
m_detailHint->setText(QStringLiteral("确认后将切换为原始背景显示。"));
m_detailTitle->setText(QStringLiteral("原始背景"));
m_algoDetails->setCurrentWidget(m_originalDetail);
} else {
m_detailTitle->setText(QStringLiteral("第 2 步:模型补全(SDXL Inpaint"));
m_detailHint->setText(QStringLiteral("输入提示词(可选),点击应用后将生成预览。"));
m_detailTitle->setText(QStringLiteral("模型补全"));
m_algoDetails->setCurrentWidget(m_modelDetail);
}
m_pages->setCurrentWidget(m_pageDetail);
@@ -34,7 +34,6 @@ private:
QWidget* m_pageDetail = nullptr;
QLabel* m_detailTitle = nullptr;
QLabel* m_detailHint = nullptr;
QStackedWidget* m_algoDetails = nullptr;
QWidget* m_copyDetail = nullptr;
+3 -2
View File
@@ -1,9 +1,10 @@
#include "dialogs/EntityFinalizeDialog.h"
#include "widgets/CompactNumericSpinBox.h"
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLineEdit>
#include <QDoubleSpinBox>
#include <QVBoxLayout>
EntityFinalizeDialog::EntityFinalizeDialog(QWidget* parent)
@@ -18,7 +19,7 @@ EntityFinalizeDialog::EntityFinalizeDialog(QWidget* parent)
m_name->setPlaceholderText(QStringLiteral("例如:entity-1、人物、树、建筑…"));
form->addRow(QStringLiteral("名称"), m_name);
m_userScale = new QDoubleSpinBox(this);
m_userScale = new gui::CompactDoubleSpinBox(this);
m_userScale->setDecimals(3);
m_userScale->setRange(0.01, 50.0);
m_userScale->setSingleStep(0.05);
+5 -1
View File
@@ -1,5 +1,8 @@
#include "dialogs/EntityIntroPopup.h"
#include "core/image/ImageDecodeConfig.h"
#include "core/image/ImageFileLoader.h"
#include <QDir>
#include <QFileInfo>
#include <QGuiApplication>
@@ -95,7 +98,8 @@ void EntityIntroPopup::setContent(const core::EntityIntroContent& content) {
auto* lab = new QLabel(m_imagesHost);
lab->setAlignment(Qt::AlignCenter);
if (!abs.isEmpty() && QFileInfo::exists(abs)) {
QPixmap pm(abs);
QPixmap pm = QPixmap::fromImage(
core::image_file::loadImageLimited(abs, core::image_decode::kPreviewMaxPixels));
if (!pm.isNull()) {
if (pm.width() > kMaxThumb || pm.height() > kMaxThumb) {
pm = pm.scaled(kMaxThumb, kMaxThumb, Qt::KeepAspectRatio, Qt::SmoothTransformation);
+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();
}
@@ -3,6 +3,7 @@
#include <QDialog>
#include <QString>
#include <QStringList>
#include <QVector>
namespace core {
class ProjectWorkspace;
@@ -27,11 +28,14 @@ private slots:
void onClearCurrentFrame();
void onBatchImportFiles();
void onBatchImportFolder();
void onGenerateAiFrames();
private:
void rebuildFrameList();
void updatePreviewForFrame(int frame);
bool applyImageToFrame(int frame, const QString& absImagePath);
QVector<int> targetFramesForWrite(int requestedCount) const;
QByteArray currentEntityPngBytes(QString* outError) const;
private:
core::ProjectWorkspace& m_workspace;
@@ -46,6 +50,7 @@ private:
QPushButton* m_btnClear = nullptr;
QPushButton* m_btnImportFiles = nullptr;
QPushButton* m_btnImportFolder = nullptr;
QPushButton* m_btnAiGenerate = nullptr;
QString m_defaultImageAbs;
};
+431 -93
View File
@@ -1,64 +1,122 @@
#include "dialogs/ImageCropDialog.h"
#include "core/image/ImageDecodeConfig.h"
#include "core/image/ImageFileLoader.h"
#include <QBoxLayout>
#include <QCursor>
#include <QDialogButtonBox>
#include <QLabel>
#include <QMouseEvent>
#include <QPainter>
#include <QPushButton>
#include <QImageReader>
#include <QResizeEvent>
#include <QWheelEvent>
#include <QtMath>
#include <algorithm>
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
# define CROP_LOCAL_POS_M(E) (E)->position()
# define CROP_LOCAL_POS_W(E) (E)->position()
#else
# define CROP_LOCAL_POS_M(E) (E)->localPos()
# define CROP_LOCAL_POS_W(E) (E)->posF()
#endif
namespace {
constexpr int kMinCropSidePx = 2;
/// 相对「适应窗口」倍率的乘子;范围极大以便实质任意缩放(仍避免数值溢出)
constexpr qreal kZoomMulMin = 1e-12;
constexpr qreal kZoomMulMax = 1e12;
constexpr qreal kHandleViewPx = 7.0;
enum class HitPart : quint8 {
None,
Inside,
N,
Ne,
E,
Se,
S,
Sw,
W,
Nw,
/// 平移视图(选区外左键拖动)
PanView
};
QRect clampRectToBounds(QRect r, const QRect& bounds) {
r = r.normalized();
if (r.width() < kMinCropSidePx || r.height() < kMinCropSidePx) {
return {};
}
if (r.left() < bounds.left()) {
r.moveLeft(bounds.left());
}
if (r.top() < bounds.top()) {
r.moveTop(bounds.top());
}
if (r.right() > bounds.right()) {
r.moveRight(bounds.right());
}
if (r.bottom() > bounds.bottom()) {
r.moveBottom(bounds.bottom());
}
if (r.width() < kMinCropSidePx || r.height() < kMinCropSidePx) {
return {};
}
return r;
}
} // namespace
class ImageCropDialog::CropView final : public QWidget {
public:
explicit CropView(QWidget* parent = nullptr)
: QWidget(parent) {
setMouseTracking(true);
setFocusPolicy(Qt::WheelFocus);
setMinimumSize(480, 320);
}
void setImage(const QImage& img) {
m_image = img;
m_selection = {};
m_zoom = 1.0;
m_pan = QPointF();
m_selImage = m_image.isNull() ? QRect() : QRect(QPoint(0, 0), m_image.size());
m_dragPart = HitPart::None;
updateGeometry();
update();
}
bool hasSelection() const { return !m_selection.isNull() && m_selection.width() > 0 && m_selection.height() > 0; }
bool hasSelection() const {
return !m_image.isNull() && m_selImage.width() >= kMinCropSidePx && m_selImage.height() >= kMinCropSidePx;
}
QRect selectionInImagePixels() const {
if (m_image.isNull() || !hasSelection()) {
return {};
}
const auto map = viewToImageTransform();
// selection 是 view 坐标;映射到 image 像素坐标
const QRectF selF = QRectF(m_selection).normalized();
bool invertible = false;
const QTransform inv = map.inverted(&invertible);
if (!invertible) {
return {};
}
const QPointF topLeftImg = inv.map(selF.topLeft());
const QPointF bottomRightImg = inv.map(selF.bottomRight());
// 使用 floor/ceil,避免因为取整导致宽高变 0
const int left = qFloor(std::min(topLeftImg.x(), bottomRightImg.x()));
const int top = qFloor(std::min(topLeftImg.y(), bottomRightImg.y()));
const int right = qCeil(std::max(topLeftImg.x(), bottomRightImg.x()));
const int bottom = qCeil(std::max(topLeftImg.y(), bottomRightImg.y()));
QRect r(QPoint(left, top), QPoint(right, bottom));
r = r.normalized().intersected(QRect(0, 0, m_image.width(), m_image.height()));
return r;
return m_selImage.normalized().intersected(QRect(0, 0, m_image.width(), m_image.height()));
}
void resetSelection() {
m_selection = {};
if (!m_image.isNull()) {
m_selImage = QRect(QPoint(0, 0), m_image.size());
} else {
m_selImage = {};
}
update();
}
protected:
void resizeEvent(QResizeEvent* e) override {
QWidget::resizeEvent(e);
update();
}
void paintEvent(QPaintEvent*) override {
QPainter p(this);
p.fillRect(rect(), palette().window());
@@ -69,84 +127,362 @@ protected:
return;
}
const auto map = viewToImageTransform();
qreal eff = 0;
QPointF tl;
computeLayout(eff, tl);
const QTransform imgToView = imageToViewTransform(eff, tl);
p.setRenderHint(QPainter::SmoothPixmapTransform, true);
p.setTransform(map);
p.setTransform(imgToView);
p.drawImage(QPoint(0, 0), m_image);
p.resetTransform();
if (hasSelection()) {
// 避免 CompositionMode_Clear 在某些平台/样式下表现异常:
// 用“围绕选区画四块遮罩”的方式实现高亮裁剪区域。
const QRect sel = m_selection.normalized().intersected(rect());
const QColor shade(0, 0, 0, 120);
const QRectF selView = imgToView.mapRect(QRectF(m_selImage));
const QRect sel = selView.toAlignedRect().intersected(rect());
// 上
p.fillRect(QRect(0, 0, width(), sel.top()), shade);
// 下
p.fillRect(QRect(0, sel.bottom(), width(), height() - sel.bottom()), shade);
// 左
p.fillRect(QRect(0, sel.top(), sel.left(), sel.height()), shade);
// 右
p.fillRect(QRect(sel.right(), sel.top(), width() - sel.right(), sel.height()), shade);
const QColor shade(0, 0, 0, 120);
p.fillRect(QRect(0, 0, width(), sel.top()), shade);
p.fillRect(QRect(0, sel.bottom(), width(), height() - sel.bottom()), shade);
p.fillRect(QRect(0, sel.top(), sel.left(), sel.height()), shade);
p.fillRect(QRect(sel.right(), sel.top(), width() - sel.right(), sel.height()), shade);
p.setPen(QPen(QColor(255, 255, 255, 220), 2));
p.drawRect(sel);
p.setPen(QPen(QColor(255, 255, 255, 230), 2));
p.setBrush(Qt::NoBrush);
p.drawRect(sel);
const qreal hsz = kHandleViewPx;
const QPointF c[4] = {
selView.topLeft(),
selView.topRight(),
selView.bottomRight(),
selView.bottomLeft(),
};
p.setPen(QPen(QColor(255, 255, 255, 240), 1));
p.setBrush(QColor(255, 255, 255, 220));
for (const QPointF& pt : c) {
p.drawRect(QRectF(pt.x() - hsz * 0.5, pt.y() - hsz * 0.5, hsz, hsz));
}
}
void wheelEvent(QWheelEvent* e) override {
if (m_image.isNull()) {
e->ignore();
return;
}
qreal effOld = 0;
QPointF tlOld;
computeLayout(effOld, tlOld);
const QPointF cursor = CROP_LOCAL_POS_W(e);
const QPointF imgPt((cursor.x() - tlOld.x()) / effOld, (cursor.y() - tlOld.y()) / effOld);
const qreal steps = e->angleDelta().y() / 120.0;
const qreal factor = std::pow(1.1, steps);
const qreal newZoom = std::clamp(m_zoom * factor, kZoomMulMin, kZoomMulMax);
if (qFuzzyCompare(newZoom, m_zoom)) {
e->accept();
return;
}
m_zoom = newZoom;
qreal effNew = 0;
QPointF tlIgnored;
computeLayout(effNew, tlIgnored);
const QPointF tlNew(cursor.x() - imgPt.x() * effNew, cursor.y() - imgPt.y() * effNew);
const qreal iw = m_image.width();
const qreal ih = m_image.height();
const qreal vw = width();
const qreal vh = height();
const QPointF centerOff((vw - iw * effNew) / 2.0, (vh - ih * effNew) / 2.0);
m_pan = tlNew - centerOff;
e->accept();
update();
}
void mousePressEvent(QMouseEvent* e) override {
if (m_image.isNull() || e->button() != Qt::LeftButton) {
return;
}
m_dragging = true;
m_anchor = e->position().toPoint();
m_selection = QRect(m_anchor, m_anchor);
update();
qreal eff = 0;
QPointF tl;
computeLayout(eff, tl);
const QPointF viewPos = CROP_LOCAL_POS_M(e);
const QPointF img = viewToImage(viewPos, eff, tl);
m_dragPart = hitTest(img, eff);
if (m_dragPart == HitPart::None) {
m_dragPart = HitPart::PanView;
}
if (m_dragPart == HitPart::PanView) {
m_pressView = viewPos;
m_panAtPress = m_pan;
setCursor(Qt::ClosedHandCursor);
return;
}
m_pressImg = img;
m_startSel = m_selImage;
updateCursorForHover(viewPos, eff, tl);
}
void mouseMoveEvent(QMouseEvent* e) override {
if (!m_dragging) {
if (m_image.isNull()) {
return;
}
const QPoint cur = e->position().toPoint();
m_selection = QRect(m_anchor, cur).normalized();
update();
qreal eff = 0;
QPointF tl;
computeLayout(eff, tl);
const QPointF viewPos = CROP_LOCAL_POS_M(e);
if ((e->buttons() & Qt::LeftButton) && m_dragPart == HitPart::PanView) {
m_pan = m_panAtPress + (viewPos - m_pressView);
update();
return;
}
if ((e->buttons() & Qt::LeftButton) && m_dragPart != HitPart::None && m_dragPart != HitPart::PanView) {
const QPointF img = viewToImage(viewPos, eff, tl);
applyDrag(m_dragPart, m_pressImg, img, m_startSel);
update();
return;
}
updateCursorForHover(viewPos, eff, tl);
}
void mouseReleaseEvent(QMouseEvent* e) override {
if (e->button() != Qt::LeftButton) {
return;
if (e->button() == Qt::LeftButton) {
m_dragPart = HitPart::None;
unsetCursor();
}
m_dragging = false;
update();
QWidget::mouseReleaseEvent(e);
}
private:
QTransform viewToImageTransform() const {
// 让图片按比例 fit 到 view 中居中显示
const QSizeF viewSize = size();
const QSizeF imgSize = m_image.size();
const qreal sx = viewSize.width() / imgSize.width();
const qreal sy = viewSize.height() / imgSize.height();
const qreal s = std::min(sx, sy);
const qreal drawW = imgSize.width() * s;
const qreal drawH = imgSize.height() * s;
const qreal offsetX = (viewSize.width() - drawW) / 2.0;
const qreal offsetY = (viewSize.height() - drawH) / 2.0;
void computeLayout(qreal& outEffScale, QPointF& outTopLeft) const {
const qreal vw = width();
const qreal vh = height();
const qreal iw = m_image.width();
const qreal ih = m_image.height();
if (iw <= 0 || ih <= 0 || vw <= 1 || vh <= 1) {
outEffScale = 1.0;
outTopLeft = QPointF();
return;
}
const qreal fit = std::min(vw / iw, vh / ih);
const qreal eff = fit * m_zoom;
outEffScale = eff;
outTopLeft = m_pan + QPointF((vw - iw * eff) / 2.0, (vh - ih * eff) / 2.0);
}
QTransform imageToViewTransform(qreal eff, const QPointF& tl) const {
QTransform t;
t.translate(offsetX, offsetY);
t.scale(s, s);
t.translate(tl.x(), tl.y());
t.scale(eff, eff);
return t;
}
QPointF viewToImage(const QPointF& viewPt, qreal eff, const QPointF& tl) const {
return QPointF((viewPt.x() - tl.x()) / eff, (viewPt.y() - tl.y()) / eff);
}
qreal marginImage(qreal eff) const {
return std::max<qreal>(kMinCropSidePx, kHandleViewPx / std::max(eff, 1e-6));
}
HitPart hitTest(const QPointF& img, qreal eff) const {
const qreal iw = m_image.width();
const qreal ih = m_image.height();
const QRectF boundsF(0, 0, iw, ih);
if (!boundsF.contains(img)) {
return HitPart::PanView;
}
const qreal m = marginImage(eff);
const QRect r = m_selImage.normalized();
const bool onN = std::abs(img.y() - r.top()) <= m && img.x() >= r.left() - m && img.x() <= r.right() + m;
const bool onS = std::abs(img.y() - r.bottom()) <= m && img.x() >= r.left() - m && img.x() <= r.right() + m;
const bool onW = std::abs(img.x() - r.left()) <= m && img.y() >= r.top() - m && img.y() <= r.bottom() + m;
const bool onE = std::abs(img.x() - r.right()) <= m && img.y() >= r.top() - m && img.y() <= r.bottom() + m;
const bool cnw = std::abs(img.x() - r.left()) <= m && std::abs(img.y() - r.top()) <= m;
const bool cne = std::abs(img.x() - r.right()) <= m && std::abs(img.y() - r.top()) <= m;
const bool cse = std::abs(img.x() - r.right()) <= m && std::abs(img.y() - r.bottom()) <= m;
const bool csw = std::abs(img.x() - r.left()) <= m && std::abs(img.y() - r.bottom()) <= m;
if (cnw) {
return HitPart::Nw;
}
if (cne) {
return HitPart::Ne;
}
if (cse) {
return HitPart::Se;
}
if (csw) {
return HitPart::Sw;
}
if (onN && !onW && !onE) {
return HitPart::N;
}
if (onS && !onW && !onE) {
return HitPart::S;
}
if (onW && !onN && !onS) {
return HitPart::W;
}
if (onE && !onN && !onS) {
return HitPart::E;
}
const QRect fullImage(0, 0, m_image.width(), m_image.height());
if (r.contains(img.toPoint())) {
// 选区为整图时没有「可平移的选区」,框内左键用于平移视图
if (r.normalized() == fullImage.normalized()) {
return HitPart::PanView;
}
return HitPart::Inside;
}
return HitPart::PanView;
}
void updateCursorForHover(const QPointF& viewPt, qreal eff, const QPointF& tl) {
if (m_dragPart != HitPart::None) {
return;
}
const QPointF img = viewToImage(viewPt, eff, tl);
const HitPart h = hitTest(img, eff);
switch (h) {
case HitPart::PanView:
setCursor(Qt::OpenHandCursor);
break;
case HitPart::Inside:
setCursor(Qt::SizeAllCursor);
break;
case HitPart::N:
setCursor(Qt::SizeVerCursor);
break;
case HitPart::S:
setCursor(Qt::SizeVerCursor);
break;
case HitPart::W:
setCursor(Qt::SizeHorCursor);
break;
case HitPart::E:
setCursor(Qt::SizeHorCursor);
break;
case HitPart::Nw:
case HitPart::Se:
setCursor(Qt::SizeFDiagCursor);
break;
case HitPart::Ne:
case HitPart::Sw:
setCursor(Qt::SizeBDiagCursor);
break;
default:
unsetCursor();
break;
}
}
void applyDrag(HitPart part, const QPointF& pressImg, const QPointF& curImg, const QRect& start) {
const QRect bounds(0, 0, m_image.width(), m_image.height());
QRect r = start.normalized();
const int L = r.left();
const int T = r.top();
const int R = r.right();
const int B = r.bottom();
auto clampX = [&](int x) { return std::clamp(x, bounds.left(), bounds.right()); };
auto clampY = [&](int y) { return std::clamp(y, bounds.top(), bounds.bottom()); };
switch (part) {
case HitPart::Inside: {
const QPoint delta = (curImg - pressImg).toPoint();
r.translate(delta);
r = clampRectToBounds(r, bounds);
break;
}
case HitPart::N: {
int nt = clampY(int(std::round(curImg.y())));
nt = std::min(nt, B - kMinCropSidePx);
r = QRect(L, nt, R - L + 1, B - nt + 1);
break;
}
case HitPart::S: {
int nb = clampY(int(std::round(curImg.y())));
nb = std::max(nb, T + kMinCropSidePx);
r = QRect(L, T, R - L + 1, nb - T + 1);
break;
}
case HitPart::W: {
int nl = clampX(int(std::round(curImg.x())));
nl = std::min(nl, R - kMinCropSidePx);
r = QRect(nl, T, R - nl + 1, B - T + 1);
break;
}
case HitPart::E: {
int nr = clampX(int(std::round(curImg.x())));
nr = std::max(nr, L + kMinCropSidePx);
r = QRect(L, T, nr - L + 1, B - T + 1);
break;
}
case HitPart::Nw: {
int nl = clampX(int(std::round(curImg.x())));
int nt = clampY(int(std::round(curImg.y())));
nl = std::min(nl, R - kMinCropSidePx);
nt = std::min(nt, B - kMinCropSidePx);
r = QRect(QPoint(nl, nt), QPoint(R, B)).normalized();
break;
}
case HitPart::Ne: {
int nr = clampX(int(std::round(curImg.x())));
int nt = clampY(int(std::round(curImg.y())));
nr = std::max(nr, L + kMinCropSidePx);
nt = std::min(nt, B - kMinCropSidePx);
r = QRect(QPoint(L, nt), QPoint(nr, B)).normalized();
break;
}
case HitPart::Se: {
int nr = clampX(int(std::round(curImg.x())));
int nb = clampY(int(std::round(curImg.y())));
nr = std::max(nr, L + kMinCropSidePx);
nb = std::max(nb, T + kMinCropSidePx);
r = QRect(QPoint(L, T), QPoint(nr, nb)).normalized();
break;
}
case HitPart::Sw: {
int nl = clampX(int(std::round(curImg.x())));
int nb = clampY(int(std::round(curImg.y())));
nl = std::min(nl, R - kMinCropSidePx);
nb = std::max(nb, T + kMinCropSidePx);
r = QRect(QPoint(nl, T), QPoint(R, nb)).normalized();
break;
}
case HitPart::PanView:
break;
default:
break;
}
m_selImage = clampRectToBounds(r, bounds);
if (m_selImage.isEmpty()) {
m_selImage = start;
}
}
private:
QImage m_image;
bool m_dragging = false;
QPoint m_anchor;
QRect m_selection;
QRect m_selImage;
double m_zoom = 1.0;
QPointF m_pan;
HitPart m_dragPart = HitPart::None;
QPointF m_pressImg;
QRect m_startSel;
QPointF m_pressView;
QPointF m_panAtPress;
};
ImageCropDialog::ImageCropDialog(const QString& imagePath, QWidget* parent)
@@ -160,34 +496,30 @@ ImageCropDialog::ImageCropDialog(const QString& imagePath, QWidget* parent)
}
void ImageCropDialog::loadImageOrClose() {
// Qt 默认的 image allocation limit 较小(常见为 256MB),超大分辨率图会被拒绝。
// 这里用 QImageReader 并提高 limit;同时对极端大图按像素数上限自动缩放,避免 OOM。
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
QImageReader::setAllocationLimit(1024); // MB
#endif
QImageReader reader(m_imagePath);
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));
}
if (!core::image_file::probeImagePixelSize(m_imagePath, &m_sourceLogicalSize)) {
m_sourceLogicalSize = QSize();
}
m_image = reader.read();
m_image =
core::image_file::loadImageLimited(m_imagePath, core::image_decode::kPreviewMaxPixels);
if (m_image.isNull()) {
reject();
return;
}
if (!m_sourceLogicalSize.isValid() || m_sourceLogicalSize.width() <= 0 ||
m_sourceLogicalSize.height() <= 0) {
m_sourceLogicalSize = m_image.size();
}
}
void ImageCropDialog::rebuildUi() {
auto* root = new QVBoxLayout(this);
auto* hint = new QLabel(QStringLiteral("拖拽选择裁剪区域(不选则使用整张图)。"), this);
auto* hint = new QLabel(
QStringLiteral(
"默认已框选整张图。滚轮可连续缩放;拖拽边或角调整裁剪,框内拖拽移动选区;选区外或留白处左键拖动平移视图。「重置」恢复整图选区。"
" 若已链接 libvips,超大图用流式解码与预览;否则回退 Qt。确定裁剪后坐标按原图逻辑像素对齐。"),
this);
hint->setWordWrap(true);
root->addWidget(hint);
m_view = new CropView(this);
@@ -196,7 +528,7 @@ void ImageCropDialog::rebuildUi() {
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
m_okButton = buttons->button(QDialogButtonBox::Ok);
auto* resetBtn = new QPushButton(QStringLiteral("重置选择"), this);
auto* resetBtn = new QPushButton(QStringLiteral("重置"), this);
buttons->addButton(resetBtn, QDialogButtonBox::ActionRole);
connect(resetBtn, &QPushButton::clicked, this, &ImageCropDialog::onReset);
@@ -213,7 +545,14 @@ QRect ImageCropDialog::selectedRectInImagePixels() const {
if (!m_view) {
return {};
}
return m_view->selectionInImagePixels();
const QRect inPreview = m_view->selectionInImagePixels();
if (!inPreview.isValid() || m_image.isNull()) {
return {};
}
if (m_sourceLogicalSize.isValid() && m_sourceLogicalSize != m_image.size()) {
return core::image_decode::mapRectBetweenSizes(inPreview, m_image.size(), m_sourceLogicalSize);
}
return inPreview;
}
void ImageCropDialog::onReset() {
@@ -225,4 +564,3 @@ void ImageCropDialog::onReset() {
void ImageCropDialog::onOk() {
accept();
}
+3
View File
@@ -3,6 +3,7 @@
#include <QDialog>
#include <QImage>
#include <QRect>
#include <QSize>
class QLabel;
class QPushButton;
@@ -30,5 +31,7 @@ private:
QString m_imagePath;
QImage m_image;
/// read() 前 reader.size() 得到的原图逻辑尺寸(与 m_image 可能为缩放预览不一致)
QSize m_sourceLogicalSize;
};
+2 -2
View File
@@ -28,7 +28,7 @@ static QScrollArea* wrapScroll(QWidget* child, QWidget* parent) {
InpaintPreviewDialog::InpaintPreviewDialog(const QString& title, QWidget* parent)
: QDialog(parent) {
setModal(true);
setMinimumSize(860, 520);
setMinimumSize(720, 480);
setWindowTitle(title);
auto* root = new QVBoxLayout(this);
@@ -49,7 +49,7 @@ InpaintPreviewDialog::InpaintPreviewDialog(const QString& title, QWidget* parent
auto* btns = new QDialogButtonBox(this);
btns->addButton(QStringLiteral("取消"), QDialogButtonBox::RejectRole);
btns->addButton(QStringLiteral("接受并写回"), QDialogButtonBox::AcceptRole);
btns->addButton(QStringLiteral("写回"), QDialogButtonBox::AcceptRole);
connect(btns, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(btns, &QDialogButtonBox::rejected, this, &QDialog::reject);
root->addWidget(btns);