diff --git a/tools/chatgui/src/avatars/avatarcrop.cpp b/tools/chatgui/src/avatars/avatarcrop.cpp index 5c5bfc19..437fac66 100644 --- a/tools/chatgui/src/avatars/avatarcrop.cpp +++ b/tools/chatgui/src/avatars/avatarcrop.cpp @@ -11,10 +11,12 @@ #include #include #include +#include #include #include #include #include +#include extern "C" { #include "debug_config.h" } @@ -29,10 +31,13 @@ public: double scale() const { return qMax(frame().width()/originalSize.width(), frame().height()/originalSize.height()) * zoom; } void clamp() { if (image.isNull()) return; - const double s = scale(), mx = (originalSize.width()*s-frame().width())/2, my = (originalSize.height()*s-frame().height())/2; + const double s = scale(); + const double mx = qMax(0.0, (originalSize.width()*s-frame().width())/2); + const double my = qMax(0.0, (originalSize.height()*s-frame().height())/2); offset = {qBound(-mx, offset.x(), mx), qBound(-my, offset.y(), my)}; } - QRectF selection() const { + QRectF selection() { + clamp(); const double s = scale(); const QPointF top = QPointF(width()/2.0,height()/2.0) + offset - QPointF(originalSize.width()*s/2, originalSize.height()*s/2); const QRectF f = frame(); @@ -45,56 +50,91 @@ protected: clamp(); const double s = scale(); const QSizeF size(originalSize.width()*s, originalSize.height()*s); p.setRenderHint(QPainter::SmoothPixmapTransform); p.setRenderHint(QPainter::Antialiasing); - p.drawImage(QRectF(QPointF(width()/2.0,height()/2.0)+offset-QPointF(size.width()/2,size.height()/2), size), image); + const QRectF photo(QPointF(width()/2.0,height()/2.0)+offset-QPointF(size.width()/2,size.height()/2), size); + const QRectF visible = photo.intersected(rect()); + const QRectF sample((visible.x()-photo.x())/photo.width()*image.width(), (visible.y()-photo.y())/photo.height()*image.height(), + visible.width()/photo.width()*image.width(), visible.height()/photo.height()*image.height()); + p.drawImage(visible, image, sample); QPainterPath mask; mask.addRect(rect()); mask.addEllipse(frame()); p.fillPath(mask, QColor(0,0,0,150)); p.setPen(QPen(Qt::white, 2)); p.drawEllipse(frame()); } void wheelEvent(QWheelEvent* e) override { - if (image.isNull()) return; - const double previous = zoom; zoom = qBound(1.0, zoom * (e->angleDelta().y()>0 ? 1.15 : 1/1.15), 16.0); + const int delta = e->angleDelta().y() ? e->angleDelta().y() : e->pixelDelta().y(); + if (image.isNull() || !delta) { e->ignore(); return; } + const double previous = zoom; + const double maximum = qMin(16.0, double(qMin(originalSize.width(), originalSize.height()))); + zoom = qBound(1.0, zoom * (delta > 0 ? 1.15 : 1/1.15), maximum); offset *= zoom/previous; clamp(); update(); e->accept(); } - void mousePressEvent(QMouseEvent* e) override { if (e->button()==Qt::LeftButton) { last=e->pos(); dragging=true; } } - void mouseMoveEvent(QMouseEvent* e) override { if (dragging) { offset+=e->pos()-last; last=e->pos(); clamp(); update(); } } - void mouseReleaseEvent(QMouseEvent*) override { dragging=false; } + void mousePressEvent(QMouseEvent* e) override { + if (e->button() == Qt::LeftButton && !image.isNull()) { last = e->pos(); dragging = true; setCursor(Qt::ClosedHandCursor); } + } + void mouseMoveEvent(QMouseEvent* e) override { + if (!(e->buttons() & Qt::LeftButton)) { dragging = false; setCursor(Qt::OpenHandCursor); } + if (dragging) { offset += e->pos()-last; last = e->pos(); clamp(); update(); } + } + void mouseReleaseEvent(QMouseEvent* e) override { + if (e->button() == Qt::LeftButton) { dragging = false; setCursor(Qt::OpenHandCursor); } + } private: double zoom = 1; QPointF offset; QPoint last; bool dragging = false; }; } bool selectAvatarCrop(const QString& source, QRectF& crop, QWidget* parent) { - QDialog dialog(parent); dialog.setWindowTitle(QCoreApplication::translate("ChatGui", "Crop avatar")); dialog.resize(620,650); - auto* layout = new QVBoxLayout(&dialog); auto* view = new CropView(&dialog); layout->addWidget(view, 1); + QPointer dialog = new QDialog(parent); + auto release = qScopeGuard([&] { delete dialog.data(); }); + dialog->setWindowTitle(QCoreApplication::translate("ChatGui", "Crop avatar")); dialog->resize(620,650); + auto* layout = new QVBoxLayout(dialog); auto* view = new CropView(dialog); layout->addWidget(view, 1); view->setObjectName("avatarCropView"); - auto* status = new QLabel(QCoreApplication::translate("ChatGui", "Drag photo and use the wheel to fit the circle"), &dialog); - layout->addWidget(status); auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); + auto* status = new QLabel(QCoreApplication::translate("ChatGui", "Drag photo and use the wheel to fit the circle"), dialog); + layout->addWidget(status); auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, dialog); buttons->button(QDialogButtonBox::Ok)->setText(QCoreApplication::translate("ChatGui", "Upload avatar")); buttons->button(QDialogButtonBox::Ok)->setObjectName("avatarCropUpload"); buttons->button(QDialogButtonBox::Ok)->setEnabled(false); layout->addWidget(buttons); - QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); - QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + QObject::connect(buttons, &QDialogButtonBox::accepted, dialog, &QDialog::accept); + QObject::connect(buttons, &QDialogButtonBox::rejected, dialog, &QDialog::reject); + // Получатель живёт до завершения worker, независимо от уничтожения окна/parent. + QObject completion; QThreadPool pool; pool.setMaxThreadCount(1); - pool.start(QRunnable::create([source, &dialog, view, status, buttons] { + pool.start(QRunnable::create([source, &completion, dialog, view, status, buttons] { QImageReader reader(source); reader.setAutoTransform(true); const QSize size = reader.size(); QImage image; + QString error; QSize orientedSize=size; if (reader.transformation() & QImageIOHandler::TransformationRotate90) orientedSize.transpose(); - if (QFileInfo(source).size() <= AvatarCache::OriginalMaxBytes && size.isValid() && + if (QFileInfo(source).size() > 0 && QFileInfo(source).size() <= AvatarCache::OriginalMaxBytes && size.width() > 0 && size.height() > 0 && qint64(size.width())*size.height() <= 16*1024*1024 && (reader.format()=="jpeg" || reader.format()=="png" || reader.format()=="webp")) { - reader.setScaledSize(size.scaled(1600,1600,Qt::KeepAspectRatio)); image = reader.read(); - } - QMetaObject::invokeMethod(&dialog, [view, image, orientedSize, status, buttons] { + if (qMax(size.width(), size.height()) > 1600) { + const QSize preview = size.scaled(1600, 1600, Qt::KeepAspectRatio); + reader.setScaledSize({qMax(1, preview.width()), qMax(1, preview.height())}); + } + image = reader.read(); if (image.isNull()) error = reader.errorString(); + } else error = QStringLiteral("source is unreadable, exceeds 32 MiB/16 MP, or has an unsupported format"); + if (image.isNull()) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: editor decode failed source=%s reason=%s", qPrintable(source), qPrintable(error)); + if (!QMetaObject::invokeMethod(&completion, [dialog, view, image, orientedSize, status, buttons] { + if (!dialog) return; if (image.isNull()) { - DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: editor cannot decode selected source"); status->setText(QCoreApplication::translate("ChatGui", "Cannot load avatar image")); return; } view->image=image; view->originalSize=orientedSize; view->update(); buttons->button(QDialogButtonBox::Ok)->setEnabled(true); DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "avatar: editor loaded original=%dx%d preview=%dx%d mask=circle", orientedSize.width(), orientedSize.height(), image.width(), image.height()); - }, Qt::QueuedConnection); + }, Qt::QueuedConnection)) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "avatar: cannot queue editor preview source=%s", qPrintable(source)); })); - const int result=dialog.exec(); pool.waitForDone(); - if (result!=QDialog::Accepted) { DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "avatar: crop cancelled"); return false; } - crop=view->selection(); DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "avatar: crop selected x=%.5f y=%.5f w=%.5f h=%.5f", + const int result = dialog->exec(); pool.waitForDone(); + if (!dialog || result != QDialog::Accepted) { DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "avatar: crop cancelled owner_gone=%d", !dialog); return false; } + if (view->image.isNull() || view->originalSize.width() <= 0 || view->originalSize.height() <= 0) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: crop acceptance rejected before preview loaded source=%s", qPrintable(source)); return false; + } + const QRectF selected = view->selection().intersected(QRectF(0, 0, 1, 1)); + if (!selected.isValid() || !std::isfinite(selected.x()) || !std::isfinite(selected.y()) + || !std::isfinite(selected.width()) || !std::isfinite(selected.height()) + || selected.width()*view->originalSize.width() < 1-1e-6 + || qAbs(selected.width()*view->originalSize.width()-selected.height()*view->originalSize.height()) > 1e-6) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "avatar: invalid editor selection original=%dx%d x=%g y=%g w=%g h=%g", + view->originalSize.width(), view->originalSize.height(), selected.x(), selected.y(), selected.width(), selected.height()); return false; + } + crop = selected; DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "avatar: crop selected x=%.5f y=%.5f w=%.5f h=%.5f", crop.x(), crop.y(), crop.width(), crop.height()); return true; } diff --git a/tools/chatgui/src/avatars/avatarcrop.h b/tools/chatgui/src/avatars/avatarcrop.h index 6d0b9786..d78907fc 100644 --- a/tools/chatgui/src/avatars/avatarcrop.h +++ b/tools/chatgui/src/avatars/avatarcrop.h @@ -1,6 +1,13 @@ -/* Выбор области аватара: круглая рамка неподвижна, фото перемещается и масштабируется. */ +/* Модальный выбор квадрата аватара в GUI-потоке: неподвижная круглая рамка, фото перемещается ЛКМ. + * Вертикальное колесо (angle/pixel delta) меняет zoom в 1.15 раза, диапазон 1..min(16, меньшая сторона оригинала). + * JPEG/PNG/WebP читается в локальном worker: файл <=32 MiB, <=16 MP; preview <=1600px, без увеличения исходника. + * Функция ждёт worker перед возвратом; уничтожение parent безопасно отменяет выбор, queued preview не обращается к удалённому окну. */ #pragma once #include #include class QWidget; +/* source — локальный файл; parent — необязательный заимствованный владелец окна. + * true: crop заменён нормализованным QRectF в [0,1] после EXIF, квадратным в пикселях оригинала (не меньше1px). + * false: Cancel/уничтожение parent/ошибка загрузки или выделения, crop не меняется. + * Только выбирает координаты: не создаёт кеш/файлы и не публикует аватар; передать crop в AvatarCache::prepare. */ bool selectAvatarCrop(const QString& source, QRectF& crop, QWidget* parent);