From 8e373ced175e948dd91c8b87bccea6cb8dedb638 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:23:56 +1000 Subject: [PATCH] feat: split caching into separate service Return original image if no cache hit instead of waiting for cache to finish --- plugin/src/Caelestia/Images/CMakeLists.txt | 1 + .../Caelestia/Images/cachingimageprovider.cpp | 132 +++------------ .../Caelestia/Images/cachingimageprovider.hpp | 8 +- plugin/src/Caelestia/Images/imagecacher.cpp | 159 ++++++++++++++++++ plugin/src/Caelestia/Images/imagecacher.hpp | 38 +++++ 5 files changed, 224 insertions(+), 114 deletions(-) create mode 100644 plugin/src/Caelestia/Images/imagecacher.cpp create mode 100644 plugin/src/Caelestia/Images/imagecacher.hpp diff --git a/plugin/src/Caelestia/Images/CMakeLists.txt b/plugin/src/Caelestia/Images/CMakeLists.txt index 10707ae9..d869667d 100644 --- a/plugin/src/Caelestia/Images/CMakeLists.txt +++ b/plugin/src/Caelestia/Images/CMakeLists.txt @@ -2,6 +2,7 @@ qml_module(caelestia-images URI Caelestia.Images SOURCES cachingimageprovider.cpp + imagecacher.cpp iutils.cpp LIBRARIES Qt::Gui diff --git a/plugin/src/Caelestia/Images/cachingimageprovider.cpp b/plugin/src/Caelestia/Images/cachingimageprovider.cpp index 2bd1c8b8..45cd77a8 100644 --- a/plugin/src/Caelestia/Images/cachingimageprovider.cpp +++ b/plugin/src/Caelestia/Images/cachingimageprovider.cpp @@ -1,15 +1,12 @@ #include "cachingimageprovider.hpp" -#include -#include -#include +#include "imagecacher.hpp" + #include #include #include #include -#include #include -#include #include Q_LOGGING_CATEGORY(lcCProv, "caelestia.images.cacheprovider", QtInfoMsg) @@ -18,44 +15,9 @@ namespace caelestia::images { namespace { -const QString& cacheDir() { - static const QString s_dir = [] { - QString cache = qEnvironmentVariable("XDG_CACHE_HOME"); - if (cache.isEmpty()) - cache = QDir::homePath() + QStringLiteral("/.cache"); - return cache + QStringLiteral("/caelestia/imagecache"); - }(); - return s_dir; -} - -QString sha256sum(const QString& path) { - QFile file(path); - if (!file.open(QIODevice::ReadOnly)) { - qCWarning(lcCProv).noquote() << "sha256sum: failed to open" << path; - return {}; - } - - QCryptographicHash hash(QCryptographicHash::Sha256); - hash.addData(&file); - file.close(); - - return hash.result().toHex(); -} - -QString fillSuffix(CachingImageProvider::FillMode fillMode) { - switch (fillMode) { - case CachingImageProvider::FillMode::Crop: - return QStringLiteral("crop"); - case CachingImageProvider::FillMode::Fit: - return QStringLiteral("fit"); - default: - return QStringLiteral("stretch"); - } -} - class CachingImageResponse final : public QQuickImageResponse, public QRunnable { public: - CachingImageResponse(const QString& id, const QSize& requestedSize, CachingImageProvider::FillMode fillMode) + CachingImageResponse(const QString& id, const QSize& requestedSize, ImageCacher::FillMode fillMode) : m_id(id) , m_requestedSize(requestedSize) , m_fillMode(fillMode) { @@ -87,87 +49,39 @@ private: // Use original image if requested size is invalid if (m_requestedSize.width() <= 0 || m_requestedSize.height() <= 0) { + qCDebug(lcCProv).noquote() << "Given source size is invalid, returning original:" << path; m_image = QImage(path); - qCDebug(lcCProv) << "Given source size is invalid, not caching."; + if (m_image.isNull()) { + m_error = QStringLiteral("Failed to decode source: ") + path; + qCWarning(lcCProv).noquote() << m_error; + } return; } - const QString sha = sha256sum(path); - if (sha.isEmpty()) { - m_error = QStringLiteral("Failed to hash: ") + path; - return; + // Try to use cached image + const auto cachePath = ImageCacher::cachePathFor(path, m_requestedSize, m_fillMode); + if (!cachePath.isEmpty()) { + QImageReader cacheReader(cachePath); + if (cacheReader.canRead()) { + m_image = cacheReader.read(); + if (!m_image.isNull()) + return; + } } - // clang-format off - const QString filename = QStringLiteral("%1@%2x%3-%4.png") - .arg(sha).arg(m_requestedSize.width()).arg(m_requestedSize.height()).arg(fillSuffix(m_fillMode)); - // clang-format on - const QString cache = cacheDir() + QLatin1Char('/') + filename; + // Schedule cache job (this call will return the original image, but later ones will use cache) + ImageCacher::instance()->schedule(path, cachePath, m_requestedSize, m_fillMode); - // Check cache, if it already exists, set and return - QImageReader cacheReader(cache); - if (cacheReader.canRead()) { - m_image = cacheReader.read(); - if (!m_image.isNull()) - return; - } - - QImage image(path); - if (image.isNull()) { - m_error = QStringLiteral("Failed to decode: ") + path; + m_image = QImage(path); + if (m_image.isNull()) { + m_error = QStringLiteral("Failed to decode source: ") + path; qCWarning(lcCProv).noquote() << m_error; - return; } - - image.convertTo(QImage::Format_ARGB32); - - // Scale to requested size - switch (m_fillMode) { - case CachingImageProvider::FillMode::Crop: - image = image.scaled(m_requestedSize, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); - break; - case CachingImageProvider::FillMode::Fit: - image = image.scaled(m_requestedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); - break; - case CachingImageProvider::FillMode::Stretch: - image = image.scaled(m_requestedSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - break; - } - - if (m_fillMode == CachingImageProvider::FillMode::Stretch) { - m_image = image; - } else { - // Crop or fit - QImage canvas(m_requestedSize, QImage::Format_ARGB32); - canvas.fill(Qt::transparent); - - QPainter painter(&canvas); - painter.drawImage( - (m_requestedSize.width() - image.width()) / 2, (m_requestedSize.height() - image.height()) / 2, image); - painter.end(); - - m_image = canvas; - } - - // Save to cache - const QString parent = QFileInfo(cache).absolutePath(); - if (!QDir().mkpath(parent)) { - qCWarning(lcCProv).noquote() << "Failed to create cache dir" << parent; - return; - } - - QSaveFile saveFile(cache); - if (!saveFile.open(QIODevice::WriteOnly) || !m_image.save(&saveFile, "PNG") || !saveFile.commit()) { - qCWarning(lcCProv).noquote() << "Failed to save to" << cache << ":" << saveFile.errorString(); - return; - } - - qCDebug(lcCProv).noquote() << "Saved to" << cache; } QString m_id; QSize m_requestedSize; - CachingImageProvider::FillMode m_fillMode; + ImageCacher::FillMode m_fillMode; QImage m_image; QString m_error; }; diff --git a/plugin/src/Caelestia/Images/cachingimageprovider.hpp b/plugin/src/Caelestia/Images/cachingimageprovider.hpp index 9e9c9772..97082c76 100644 --- a/plugin/src/Caelestia/Images/cachingimageprovider.hpp +++ b/plugin/src/Caelestia/Images/cachingimageprovider.hpp @@ -1,16 +1,14 @@ #pragma once +#include "imagecacher.hpp" + #include namespace caelestia::images { class CachingImageProvider : public QQuickAsyncImageProvider { public: - enum class FillMode { - Crop, - Fit, - Stretch - }; + using FillMode = ImageCacher::FillMode; explicit CachingImageProvider(FillMode fillMode); diff --git a/plugin/src/Caelestia/Images/imagecacher.cpp b/plugin/src/Caelestia/Images/imagecacher.cpp new file mode 100644 index 00000000..dfef8f0b --- /dev/null +++ b/plugin/src/Caelestia/Images/imagecacher.cpp @@ -0,0 +1,159 @@ +#include "imagecacher.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(lcCacher, "caelestia.images.cacher", QtInfoMsg) + +namespace caelestia::images { + +namespace { + +QString sha256sum(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + qCWarning(lcCacher).noquote() << "sha256sum: failed to open" << path; + return {}; + } + + QCryptographicHash hash(QCryptographicHash::Sha256); + hash.addData(&file); + file.close(); + + return hash.result().toHex(); +} + +QString fillSuffix(ImageCacher::FillMode fillMode) { + switch (fillMode) { + case ImageCacher::FillMode::Crop: + return QStringLiteral("crop"); + case ImageCacher::FillMode::Fit: + return QStringLiteral("fit"); + default: + return QStringLiteral("stretch"); + } +} + +} // namespace + +const QString& ImageCacher::cacheDir() { + static const QString s_dir = [] { + QString cache = qEnvironmentVariable("XDG_CACHE_HOME"); + if (cache.isEmpty()) + cache = QDir::homePath() + QStringLiteral("/.cache"); + return cache + QStringLiteral("/caelestia/imagecache"); + }(); + return s_dir; +} + +QString ImageCacher::cachePathFor(const QString& sourcePath, const QSize& size, FillMode fillMode) { + const QString sha = sha256sum(sourcePath); + if (sha.isEmpty()) + return {}; + + const QString filename = + QStringLiteral("%1@%2x%3-%4.png") + .arg(sha, QString::number(size.width()), QString::number(size.height()), fillSuffix(fillMode)); + + return cacheDir() + QLatin1Char('/') + filename; +} + +ImageCacher* ImageCacher::instance() { + static ImageCacher s_instance; + return &s_instance; +} + +ImageCacher::ImageCacher(QObject* parent) + : QObject(parent) {} + +void ImageCacher::schedule(const QString& sourcePath, const QSize& size, FillMode fillMode) { + schedule(sourcePath, cachePathFor(sourcePath, size, fillMode), size, fillMode); +} + +void ImageCacher::schedule(const QString& sourcePath, const QString& cachePath, const QSize& size, FillMode fillMode) { + if (cachePath.isEmpty()) + return; + + { + QMutexLocker locker(&m_mutex); + if (m_inflight.contains(cachePath)) + return; + m_inflight.insert(cachePath); + } + + QThreadPool::globalInstance()->start([this, sourcePath, cachePath, size, fillMode]() { + runJob(sourcePath, cachePath, size, fillMode); + QMutexLocker locker(&m_mutex); + m_inflight.remove(cachePath); + }); +} + +void ImageCacher::runJob(const QString& sourcePath, const QString& cachePath, const QSize& size, FillMode fillMode) { + if (QFile::exists(cachePath)) { + return; + } + + QImage image(sourcePath); + if (image.isNull()) { + qCWarning(lcCacher).noquote() << "Failed to decode source" << sourcePath; + return; + } + + Qt::AspectRatioMode scaleMode; + switch (fillMode) { + case FillMode::Crop: + scaleMode = Qt::KeepAspectRatioByExpanding; + break; + case FillMode::Fit: + scaleMode = Qt::KeepAspectRatio; + break; + case FillMode::Stretch: + scaleMode = Qt::IgnoreAspectRatio; + break; + } + + image.convertTo(QImage::Format_ARGB32); + image = image.scaled(size, scaleMode, Qt::SmoothTransformation); + + if (image.isNull()) { + qCWarning(lcCacher).noquote() << "Failed to scale" << sourcePath; + return; + } + + QImage canvas; + if (fillMode == FillMode::Stretch) { + canvas = image; + } else { + canvas = QImage(size, QImage::Format_ARGB32); + canvas.fill(Qt::transparent); + + QPainter painter(&canvas); + painter.drawImage((size.width() - image.width()) / 2, (size.height() - image.height()) / 2, image); + painter.end(); + } + + const QString parent = QFileInfo(cachePath).absolutePath(); + if (!QDir().mkpath(parent)) { + qCWarning(lcCacher).noquote() << "Failed to create cache dir" << parent; + return; + } + + QSaveFile saveFile(cachePath); + if (!saveFile.open(QIODevice::WriteOnly) || !canvas.save(&saveFile, "PNG") || !saveFile.commit()) { + qCWarning( + lcCacher, "Failed to save to %s: %s", qUtf8Printable(cachePath), qUtf8Printable(saveFile.errorString())); + return; + } + + qCDebug(lcCacher).noquote() << "Saved to" << cachePath; +} + +} // namespace caelestia::images diff --git a/plugin/src/Caelestia/Images/imagecacher.hpp b/plugin/src/Caelestia/Images/imagecacher.hpp new file mode 100644 index 00000000..79608419 --- /dev/null +++ b/plugin/src/Caelestia/Images/imagecacher.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace caelestia::images { + +class ImageCacher : public QObject { + Q_OBJECT + +public: + enum class FillMode { + Crop, + Fit, + Stretch, + }; + + static ImageCacher* instance(); + + static const QString& cacheDir(); + static QString cachePathFor(const QString& sourcePath, const QSize& size, FillMode fillMode); + + void schedule(const QString& sourcePath, const QSize& size, FillMode fillMode); + void schedule(const QString& sourcePath, const QString& cachePath, const QSize& size, FillMode fillMode); + +private: + explicit ImageCacher(QObject* parent = nullptr); + + static void runJob(const QString& sourcePath, const QString& cachePath, const QSize& size, FillMode fillMode); + + QMutex m_mutex; + QSet m_inflight; +}; + +} // namespace caelestia::images