Prepare visible image and video previews through the shared background cache

This commit is contained in:
evgeny committed 2026-10-28 15:01:29 +01:00
1 parent 468af7abbe
commit 60d2ba0fac
2 files changed
+76 -61

No files matched your search

+67 -53
View File
@@ -1,5 +1,6 @@
#include <QCoreApplication>
#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 <QDebug>
#include <QFileInfo>
#include <QFile>
#include <QImageReader>
#include <QStandardPaths>
#include <QDir>
#include <QMenu>
#include <QFileDialog>
#include <QToolTip>
@@ -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<MediaPreviewCache::Cache>(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<int>& roles) {
if (roles.isEmpty() || roles.contains(MsgVoiceFileRole) || roles.contains(MsgContentTypeRole)) m_imagePreviewTimer.start(0);
[this](const QModelIndex& first, const QModelIndex& last, const QVector<int>& 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<Candidate> candidates; QSet<QString> 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<MessageList> 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<MessageList> 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<MessageList> 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);
+9 -8
View File
@@ -14,8 +14,8 @@
#include <QThreadPool>
#include <QJsonObject>
#include <QPointer>
#include <QCache>
#include <QImage>
#include <memory>
#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<LiveStreamSource> m_streamSources; // Проверенный полный снимок выбранной группы.
RadioPanel* m_radioPanel = nullptr; /* верхняя панель устройств, VAD и PTT */
bool m_radioOn = false; // Локальный audio engine успешно включён для текущей группы.
QThreadPool m_thumbnailPool; // До двух media workers; деструктор pool дожидается jobs.
QSet<QString> m_thumbnailJobs; // Пути незавершённых jobs, без дублей.
QCache<QString, QImage> m_imagePreviews{32 * 1024 * 1024}; // Ограниченный кеш decoded-превью по неизменному пути.
QSet<QString> m_imageJobs; // Незавершённые image jobs; GUI изменяет, workers получают копию пути.
QTimer m_imagePreviewTimer; // Объединяет изменения модели до одного обхода видимой истории.
void prepareImagePreviews(); // Асинхронное декодирование локальных изображений и обновление актуальных строк модели.
std::shared_ptr<MediaPreviewCache::Cache> m_previewCache; // Общий кеш картинок/первых кадров; workers удерживают собственную ссылку.
QThreadPool m_previewPool; // Не более двух workers, включая очистку; деструктор дожидается jobs.
QSet<QString> m_previewJobs; // Неповторяющиеся path+kind в работе; очередь видимых сообщений ограничена двумя заданиями.
QTimer m_previewTimer, m_cacheCleanupTimer; // Объединение обновлений viewport и часовая очистка по созданию.
void prepareMediaPreviews(); // Видимые и соседние сообщения: worker читает кеш/исходник, результат идёт в актуальные роли.
QHash<QString, QJsonObject> m_feedback; // message_id hex → последний полный снимок реакций/опроса.
QPointer<QDialog> 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();