Qt: Rewrite cover loading/generation

This was always wrong, QPixmaps shouldn't be manipulated outside of
the UI thread, and it used to crash in debug builds.

Also uses a placeholder image instead of a black image while covers
are loading/generating.
This commit is contained in:
Stenzek 2025-01-03 19:30:54 +10:00
parent db14824d61
commit 80855090d5
No known key found for this signature in database
2 changed files with 146 additions and 50 deletions

View File

@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin <stenzek@gmail.com>
// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin <stenzek@gmail.com>
// SPDX-License-Identifier: CC-BY-NC-ND-4.0
#include "gamelistmodel.h"
@ -12,11 +12,8 @@
#include "common/path.h"
#include "common/string_util.h"
#include <QtConcurrent/QtConcurrent>
#include <QtCore/QDate>
#include <QtCore/QDateTime>
#include <QtCore/QFuture>
#include <QtCore/QFutureWatcher>
#include <QtGui/QGuiApplication>
#include <QtGui/QIcon>
#include <QtGui/QPainter>
@ -75,31 +72,99 @@ static void resizeAndPadPixmap(QPixmap* pm, int expected_width, int expected_hei
*pm = padded_image;
}
static QPixmap createPlaceholderImage(const QPixmap& placeholder_pixmap, int width, int height, float scale,
const std::string& title)
GameListCoverLoader::GameListCoverLoader(const GameList::Entry* ge, const QImage& placeholder_image, int width,
int height, float scale)
: QObject(nullptr), m_path(ge->path), m_serial(ge->serial), m_title(ge->title),
m_placeholder_image(placeholder_image), m_width(width), m_height(height), m_scale(scale),
m_dpr(qApp->devicePixelRatio())
{
const float dpr = qApp->devicePixelRatio();
QPixmap pm(placeholder_pixmap.copy());
pm.setDevicePixelRatio(dpr);
if (pm.isNull())
return QPixmap();
}
GameListCoverLoader::~GameListCoverLoader() = default;
void GameListCoverLoader::loadOrGenerateCover()
{
QPixmap image;
const std::string cover_path(GameList::GetCoverImagePath(m_path, m_serial, m_title));
if (!cover_path.empty())
{
m_image.load(QString::fromStdString(cover_path));
if (!m_image.isNull())
{
m_image.setDevicePixelRatio(m_dpr);
resizeAndPadImage();
}
}
if (m_image.isNull())
createPlaceholderImage();
// Have to pass through the UI thread, because the thread pool isn't a QThread...
// Can't create pixmaps on the worker thread, have to create it on the UI thread.
QtHost::RunOnUIThread([this]() {
if (!m_image.isNull())
emit coverLoaded(m_path, QPixmap::fromImage(m_image));
else
emit coverLoaded(m_path, QPixmap());
delete this;
});
}
void GameListCoverLoader::createPlaceholderImage()
{
m_image = m_placeholder_image.copy();
m_image.setDevicePixelRatio(m_dpr);
if (m_image.isNull())
return;
resizeAndPadImage();
resizeAndPadPixmap(&pm, width, height, dpr);
QPainter painter;
if (painter.begin(&pm))
if (painter.begin(&m_image))
{
QFont font;
font.setPointSize(std::max(static_cast<int>(32.0f * scale), 1));
font.setPointSize(std::max(static_cast<int>(32.0f * m_scale), 1));
painter.setFont(font);
painter.setPen(Qt::white);
const QRect text_rc(0, 0, static_cast<int>(static_cast<float>(width)),
static_cast<int>(static_cast<float>(height)));
painter.drawText(text_rc, Qt::AlignCenter | Qt::TextWordWrap, QString::fromStdString(title));
const QRect text_rc(0, 0, static_cast<int>(static_cast<float>(m_width)),
static_cast<int>(static_cast<float>(m_height)));
painter.drawText(text_rc, Qt::AlignCenter | Qt::TextWordWrap, QString::fromStdString(m_title));
painter.end();
}
}
return pm;
void GameListCoverLoader::resizeAndPadImage()
{
const int dpr_expected_width = DPRScale(m_width, m_dpr);
const int dpr_expected_height = DPRScale(m_height, m_dpr);
if (m_image.width() == dpr_expected_width && m_image.height() == dpr_expected_height)
return;
m_image = m_image.scaled(dpr_expected_width, dpr_expected_height, Qt::KeepAspectRatio, Qt::SmoothTransformation);
if (m_image.width() == dpr_expected_width && m_image.height() == dpr_expected_height)
return;
// QPainter works in unscaled coordinates.
int xoffs = 0;
int yoffs = 0;
if (m_image.width() < dpr_expected_width)
xoffs = DPRUnscale((dpr_expected_width - m_image.width()) / 2, m_dpr);
if (m_image.height() < dpr_expected_height)
yoffs = DPRUnscale((dpr_expected_height - m_image.height()) / 2, m_dpr);
QPixmap padded_image(dpr_expected_width, dpr_expected_height);
padded_image.setDevicePixelRatio(m_dpr);
padded_image.fill(Qt::transparent);
QPainter painter;
if (painter.begin(&padded_image))
{
painter.setCompositionMode(QPainter::CompositionMode_Source);
painter.drawImage(xoffs, yoffs, m_image);
painter.setCompositionMode(QPainter::CompositionMode_Destination);
painter.fillRect(padded_image.rect(), QColor(0, 0, 0, 0));
painter.end();
}
}
std::optional<GameListModel::Column> GameListModel::getColumnIdForName(std::string_view name)
@ -131,7 +196,11 @@ GameListModel::GameListModel(float cover_scale, bool show_cover_titles, bool sho
GameList::ReloadMemcardTimestampCache();
}
GameListModel::~GameListModel() = default;
GameListModel::~GameListModel()
{
// wait for all cover loads to finish, they're using m_placeholder_image
System::WaitForAllAsyncTasks();
}
void GameListModel::setShowGameIcons(bool enabled)
{
@ -151,8 +220,16 @@ void GameListModel::setCoverScale(float scale)
m_cover_pixmap_cache.Clear();
m_cover_scale = scale;
m_loading_pixmap = QPixmap(getCoverArtWidth(), getCoverArtHeight());
m_loading_pixmap.fill(QColor(0, 0, 0, 0));
if (m_loading_pixmap.load(QStringLiteral("%1/images/placeholder.png").arg(QtHost::GetResourcesBasePath())))
{
m_loading_pixmap.setDevicePixelRatio(qApp->devicePixelRatio());
resizeAndPadPixmap(&m_loading_pixmap, getCoverArtWidth(), getCoverArtHeight(), qApp->devicePixelRatio());
}
else
{
m_loading_pixmap = QPixmap(getCoverArtWidth(), getCoverArtHeight());
m_loading_pixmap.fill(QColor(0, 0, 0, 0));
}
emit coverScaleChanged();
}
@ -181,33 +258,17 @@ void GameListModel::reloadThemeSpecificImages()
void GameListModel::loadOrGenerateCover(const GameList::Entry* ge)
{
QFuture<QPixmap> future =
QtConcurrent::run([this, path = ge->path, title = ge->title, serial = ge->serial]() -> QPixmap {
QPixmap image;
const std::string cover_path(GameList::GetCoverImagePath(path, serial, title));
if (!cover_path.empty())
{
const float dpr = qApp->devicePixelRatio();
image = QPixmap(QString::fromStdString(cover_path));
if (!image.isNull())
{
image.setDevicePixelRatio(dpr);
resizeAndPadPixmap(&image, getCoverArtWidth(), getCoverArtHeight(), dpr);
}
}
// NOTE: Must get connected before queuing, because otherwise you risk a race.
GameListCoverLoader* loader =
new GameListCoverLoader(ge, m_placeholder_image, getCoverArtWidth(), getCoverArtHeight(), m_cover_scale);
connect(loader, &GameListCoverLoader::coverLoaded, this, &GameListModel::coverLoaded);
System::QueueAsyncTask([loader]() { loader->loadOrGenerateCover(); });
}
if (image.isNull())
image =
createPlaceholderImage(m_placeholder_pixmap, getCoverArtWidth(), getCoverArtHeight(), m_cover_scale, title);
return image;
});
// Context must be 'this' so we run on the UI thread.
future.then(this, [this, path = ge->path](QPixmap pm) {
m_cover_pixmap_cache.Insert(std::move(path), std::move(pm));
invalidateCoverForPath(path);
});
void GameListModel::coverLoaded(const std::string& path, const QPixmap& pixmap)
{
m_cover_pixmap_cache.Insert(path, pixmap);
invalidateCoverForPath(path);
}
void GameListModel::invalidateCoverForPath(const std::string& path)
@ -816,7 +877,7 @@ void GameListModel::loadCommonImages()
QtUtils::GetIconForCompatibility(static_cast<GameDatabase::CompatibilityRating>(i)).pixmap(96, 24);
}
m_placeholder_pixmap.load(QStringLiteral("%1/images/cover-placeholder.png").arg(QtHost::GetResourcesBasePath()));
m_placeholder_image.load(QStringLiteral("%1/images/cover-placeholder.png").arg(QtHost::GetResourcesBasePath()));
}
void GameListModel::setColumnDisplayNames()

View File

@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin <stenzek@gmail.com>
// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin <stenzek@gmail.com>
// SPDX-License-Identifier: CC-BY-NC-ND-4.0
#pragma once
@ -11,7 +11,9 @@
#include "common/lru_cache.h"
#include <QtCore/QAbstractTableModel>
#include <QtGui/QImage>
#include <QtGui/QPixmap>
#include <algorithm>
#include <array>
#include <optional>
@ -85,6 +87,9 @@ public:
Q_SIGNALS:
void coverScaleChanged();
private Q_SLOTS:
void coverLoaded(const std::string& path, const QPixmap& pixmap);
private:
/// The purpose of this cache is to stop us trying to constantly extract memory card icons, when we know a game
/// doesn't have any saves yet. It caches the serial:memcard_timestamp pair, and only tries extraction when the
@ -126,7 +131,7 @@ private:
std::array<QPixmap, static_cast<int>(GameList::EntryType::Count)> m_type_pixmaps;
std::array<QPixmap, static_cast<int>(GameDatabase::CompatibilityRating::Count)> m_compatibility_pixmaps;
QPixmap m_placeholder_pixmap;
QImage m_placeholder_image;
QPixmap m_loading_pixmap;
mutable PreferUnorderedStringMap<QPixmap> m_flag_pixmap_cache;
@ -135,3 +140,33 @@ private:
mutable LRUCache<std::string, QPixmap> m_memcard_pixmap_cache;
};
class GameListCoverLoader : public QObject
{
Q_OBJECT
public:
GameListCoverLoader(const GameList::Entry* ge, const QImage& placeholder_image, int width, int height, float scale);
~GameListCoverLoader();
public:
void loadOrGenerateCover();
Q_SIGNALS:
void coverLoaded(const std::string& path, const QPixmap& pixmap);
private:
void createPlaceholderImage();
void resizeAndPadImage();
std::string m_path;
std::string m_serial;
std::string m_title;
const QImage& m_placeholder_image;
int m_width;
int m_height;
float m_scale;
float m_dpr;
QImage m_image;
};