From 60d2ba0facbf236157d78e2f38d02a7abe60e6a0 Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 28 Oct 2026 15:01:29 +0100 Subject: [PATCH] Prepare visible image and video previews through the shared background cache --- tools/chatgui/src/messagelist.cpp | 120 +++++++++++++++++------------- tools/chatgui/src/messagelist.h | 17 +++-- 2 files changed, 76 insertions(+), 61 deletions(-) diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index be452a8f..5c1fd743 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -1,5 +1,6 @@ #include #include "messagelist.h" +#include "media_preview_cache.h" #include "../transport/stream_bridge.h" #include "debug_ui.h" #include "chatview.h" @@ -30,7 +31,8 @@ #include #include #include -#include +#include +#include #include #include #include @@ -358,12 +360,27 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent , m_recorder(recorder) { auto *layout = new QVBoxLayout(this); - m_imagePreviewTimer.setSingleShot(true); - connect(&m_imagePreviewTimer, &QTimer::timeout, this, &MessageList::prepareImagePreviews); - connect(m_model, &QAbstractItemModel::rowsInserted, this, [this]() { m_imagePreviewTimer.start(0); }); + const QString cacheBase = m_db && !m_db->dbPath().isEmpty() ? m_db->dbPath() : QStandardPaths::writableLocation(QStandardPaths::CacheLocation); + m_previewCache = std::make_shared(QDir(cacheBase).filePath("media_previews/v1")); + m_previewPool.setMaxThreadCount(2); m_previewTimer.setSingleShot(true); + auto cleanup = [this] { auto cache = m_previewCache; m_previewPool.start([cache] { cache->prune(); }, -1); }; + m_cacheCleanupTimer.setInterval(60 * 60 * 1000); connect(&m_cacheCleanupTimer, &QTimer::timeout, this, cleanup); + m_cacheCleanupTimer.start(); cleanup(); + m_view->viewport()->installEventFilter(this); + connect(&m_previewTimer, &QTimer::timeout, this, &MessageList::prepareMediaPreviews); + connect(m_view->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { m_previewTimer.start(0); }); + connect(m_model, &QAbstractItemModel::rowsInserted, this, [this] { m_previewTimer.start(0); }); + connect(m_model, &QAbstractItemModel::modelReset, this, [this] { m_previewTimer.start(0); }); connect(m_model, &QAbstractItemModel::dataChanged, this, - [this](const QModelIndex&, const QModelIndex&, const QVector& roles) { - if (roles.isEmpty() || roles.contains(MsgVoiceFileRole) || roles.contains(MsgContentTypeRole)) m_imagePreviewTimer.start(0); + [this](const QModelIndex& first, const QModelIndex& last, const QVector& roles) { + const bool sourceChanged = roles.isEmpty() || roles.contains(MsgVoiceFileRole) || roles.contains(MsgContentTypeRole); + const bool downloadChanged = roles.contains(MsgFileDownloadStateRole); + if (!sourceChanged && !downloadChanged) return; + for (int row = first.row(); row <= last.row(); ++row) if (auto* item = m_model->item(row)) { + if (sourceChanged) item->setData(QVariant(), MsgImagePreviewRole); + if (sourceChanged || item->data(MsgFileDownloadStateRole).toInt() == 2) item->setData(QVariant(), MsgImageErrorRole); + } + m_previewTimer.start(0); }); if (auto* manager = AvatarManager::instance()) connect(manager, &AvatarManager::changed, this, [this, manager](const QString& channel, quint64 owner) { @@ -1233,66 +1250,64 @@ void MessageList::onAttachmentDownloaded(const QString& chId, int64_t msgId) { } } - if (m.contentType == "video/mp4") prepareVideoThumbnail(item->data(MsgVoiceFileRole).toString()); } -/* Общие превью для каналов/PM; не более двух decoder workers, без запуска из paint. */ -void MessageList::prepareImagePreviews() { +bool MessageList::eventFilter(QObject* object, QEvent* event) { + if (object == m_view->viewport() && (event->type() == QEvent::Resize || event->type() == QEvent::Show)) m_previewTimer.start(0); + return QWidget::eventFilter(object, event); +} + +/* Общая очередь каналов/PM: сначала видимые сообщения, затем один экран запаса; не читаем файлы в GUI. */ +void MessageList::prepareMediaPreviews() { + struct Candidate { QString path, job; MediaPreviewCache::Kind kind; int priority; }; + QList candidates; QSet seen; + const QRect visible = m_view->viewport()->rect(); + const QRect nearby = visible.adjusted(0, -visible.height(), 0, visible.height()); for (int row = 0; row < m_model->rowCount(); ++row) { auto* item = m_model->item(row); - if (!item->data(MsgContentTypeRole).toString().startsWith("image/")) continue; + const QString type = item->data(MsgContentTypeRole).toString(); + if (!type.startsWith("image/") && type != "video/mp4") continue; + const QRect rectangle = m_view->visualRect(item->index()); + if (!rectangle.intersects(nearby)) { + if (item->data(MsgImagePreviewRole).isValid()) item->setData(QVariant(), MsgImagePreviewRole); + continue; + } const QString path = item->data(MsgVoiceFileRole).toString(); if (path.isEmpty() || item->data(MsgImagePreviewRole).isValid() || item->data(MsgImageErrorRole).toBool()) continue; - if (const auto* image = m_imagePreviews.object(path)) { - item->setData(*image, MsgImagePreviewRole); item->setData(image->isNull(), MsgImageErrorRole); continue; - } - if (m_imageJobs.contains(path)) continue; - m_imageJobs.insert(path); m_thumbnailPool.setMaxThreadCount(2); - QPointer self(this); + const auto kind = type == "video/mp4" ? MediaPreviewCache::Kind::Video : MediaPreviewCache::Kind::Image; + const QString job = path + QChar(0) + QString::number(int(kind)); + if (m_previewJobs.contains(job) || seen.contains(job)) continue; + seen.insert(job); + int priority = qAbs(rectangle.center().y() - visible.center().y()) + (rectangle.intersects(visible) ? 0 : visible.height() * 3); + candidates.append({path, job, kind, priority}); + } + std::stable_sort(candidates.begin(), candidates.end(), [](const auto& left, const auto& right) { return left.priority < right.priority; }); + for (const auto& candidate : candidates) { + if (m_previewJobs.size() >= 2) break; + m_previewJobs.insert(candidate.job); QPointer self(this); auto cache = m_previewCache; QObject* dispatcher = QCoreApplication::instance(); - m_thumbnailPool.start([self, dispatcher, path]() { - QImageReader reader(path); reader.setAutoTransform(true); - const QSize size = reader.size(); - QImage image; - if (size.isValid() && size.width() <= 16384 && size.height() <= 16384) { - reader.setScaledSize(size.scaled(600, 600, Qt::KeepAspectRatio)); image = reader.read(); - } - if (image.isNull()) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "image preview failed path=%s dimensions=%dx%d error=%s", - qPrintable(path), size.width(), size.height(), qPrintable(reader.errorString())); - else DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "image preview ready path=%s dimensions=%dx%d", qPrintable(path), image.width(), image.height()); - QMetaObject::invokeMethod(dispatcher, [self, path, image]() { + m_previewPool.start([self, dispatcher, cache, candidate] { + const auto result = cache->load(candidate.path, candidate.kind); + if (!QMetaObject::invokeMethod(dispatcher, [self, candidate, result] { if (!self) return; - self->m_imageJobs.remove(path); - self->m_imagePreviews.insert(path, new QImage(image), qMax(1, (int)image.sizeInBytes())); + self->m_previewJobs.remove(candidate.job); + const QRect nearby = self->m_view->viewport()->rect().adjusted(0, -self->m_view->viewport()->height(), 0, self->m_view->viewport()->height()); for (int r = 0; r < self->m_model->rowCount(); ++r) { auto* current = self->m_model->item(r); - if (current->data(MsgVoiceFileRole).toString() != path) continue; - current->setData(image, MsgImagePreviewRole); current->setData(image.isNull(), MsgImageErrorRole); + if (current->data(MsgVoiceFileRole).toString() != candidate.path) continue; + const QString type = current->data(MsgContentTypeRole).toString(); + if ((candidate.kind == MediaPreviewCache::Kind::Video && type != "video/mp4") + || (candidate.kind == MediaPreviewCache::Kind::Image && !type.startsWith("image/"))) continue; + if (!result.retryable && self->m_view->visualRect(current->index()).intersects(nearby)) { + current->setData(result.image, MsgImagePreviewRole); current->setData(result.image.isNull(), MsgImageErrorRole); + } } - }, Qt::QueuedConnection); - }); + self->m_previewTimer.start(0); + }, Qt::QueuedConnection)) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "preview completion could not be queued source=%s", qPrintable(candidate.path)); + }, 1); } } -void MessageList::prepareVideoThumbnail(const QString& path) { - if (path.isEmpty() || m_thumbnailJobs.contains(path) || !QFile::exists(path) || QFile::exists(path + ".jpg")) return; - m_thumbnailPool.setMaxThreadCount(2); - m_thumbnailJobs.insert(path); - QPointer self(this); - QObject* dispatcher = QCoreApplication::instance(); - m_thumbnailPool.start([self, dispatcher, path]() { - QString output = path + ".jpg"; - int rc = video_extract_thumbnail(path.toUtf8().constData(), output.toUtf8().constData(), 320); - if (rc) DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "thumbnail failed path=%s error=%d", qPrintable(path), rc); - else DEBUG_TRACE(DEBUG_CATEGORY_VIDEO, "thumbnail ready path=%s", qPrintable(output)); - QMetaObject::invokeMethod(dispatcher, [self, path]() { - if (!self) return; - self->m_thumbnailJobs.remove(path); - self->m_view->viewport()->update(); - }, Qt::QueuedConnection); - }); -} - /* Цитаты разрешаются независимо от окна истории; поздняя синхронизация обновляет уже видимые ответы. */ void MessageList::updateReply(QStandardItem* item, const QByteArray& original) { item->setData(QString::fromLatin1(original.toHex()), MsgReplyToRole); @@ -1640,7 +1655,6 @@ void MessageList::onDmMessages(const uint8_t* data, int len) { item->setData(duration, MsgVideoDurationMsRole); item->setData(message.value("width").toInt(), MsgVideoWidthRole); item->setData(message.value("height").toInt(), MsgVideoHeightRole); - prepareVideoThumbnail(path); } else if (type.startsWith("image/")) { item->setData(message.value("width").toInt(), MsgImageWidthRole); item->setData(message.value("height").toInt(), MsgImageHeightRole); diff --git a/tools/chatgui/src/messagelist.h b/tools/chatgui/src/messagelist.h index 6ce6573b..db976934 100644 --- a/tools/chatgui/src/messagelist.h +++ b/tools/chatgui/src/messagelist.h @@ -14,8 +14,8 @@ #include #include #include -#include #include +#include #include "stream_sources.h" class ChatView; @@ -31,6 +31,7 @@ class QDialog; class QMenu; class QTreeWidget; struct MessageRow; +namespace MediaPreviewCache { class Cache; } class MessageList : public QWidget { Q_OBJECT @@ -103,6 +104,8 @@ signals: void channelSettingsRequested(const QString& channelId); void linkActivated(const QString& link); +protected: + bool eventFilter(QObject* object, QEvent* event) override; // Resize/Show viewport планируют превью, paint ничего не загружает. private: // Qt children: view/model/composer/panel. Внешний db не уничтожается этим виджетом. ChatView *m_view; @@ -140,12 +143,11 @@ private: QList m_streamSources; // Проверенный полный снимок выбранной группы. RadioPanel* m_radioPanel = nullptr; /* верхняя панель устройств, VAD и PTT */ bool m_radioOn = false; // Локальный audio engine успешно включён для текущей группы. - QThreadPool m_thumbnailPool; // До двух media workers; деструктор pool дожидается jobs. - QSet m_thumbnailJobs; // Пути незавершённых jobs, без дублей. - QCache m_imagePreviews{32 * 1024 * 1024}; // Ограниченный кеш decoded-превью по неизменному пути. - QSet m_imageJobs; // Незавершённые image jobs; GUI изменяет, workers получают копию пути. - QTimer m_imagePreviewTimer; // Объединяет изменения модели до одного обхода видимой истории. - void prepareImagePreviews(); // Асинхронное декодирование локальных изображений и обновление актуальных строк модели. + std::shared_ptr m_previewCache; // Общий кеш картинок/первых кадров; workers удерживают собственную ссылку. + QThreadPool m_previewPool; // Не более двух workers, включая очистку; деструктор дожидается jobs. + QSet m_previewJobs; // Неповторяющиеся path+kind в работе; очередь видимых сообщений ограничена двумя заданиями. + QTimer m_previewTimer, m_cacheCleanupTimer; // Объединение обновлений viewport и часовая очистка по созданию. + void prepareMediaPreviews(); // Видимые и соседние сообщения: worker читает кеш/исходник, результат идёт в актуальные роли. QHash m_feedback; // message_id hex → последний полный снимок реакций/опроса. QPointer m_pollDialog; // Один диалог голосования; уничтожение owner безопасно для exec. QString m_pollDialogMessage, m_pendingPoll; // Открытый message_id и ожидаемый запрос состояния. @@ -175,7 +177,6 @@ private: // Запросить выбранное поколение PM; ожидающее удаление блокирует запрос. void requestDmMessages(const QString& channelId); // Не более двух FFmpeg jobs; GUI callback защищён QPointer. - void prepareVideoThumbnail(const QString& path); // Owned-подписка в uasync и local audio; ошибка восстанавливает кнопку. void onRadioToggled(bool on); void updateRadioUi();