feat: split caching into separate service
Return original image if no cache hit instead of waiting for cache to finish
This commit is contained in:
parent
b4d490a9bc
commit
8e373ced17
5 changed files with 224 additions and 114 deletions
|
|
@ -2,6 +2,7 @@ qml_module(caelestia-images
|
||||||
URI Caelestia.Images
|
URI Caelestia.Images
|
||||||
SOURCES
|
SOURCES
|
||||||
cachingimageprovider.cpp
|
cachingimageprovider.cpp
|
||||||
|
imagecacher.cpp
|
||||||
iutils.cpp
|
iutils.cpp
|
||||||
LIBRARIES
|
LIBRARIES
|
||||||
Qt::Gui
|
Qt::Gui
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
#include "cachingimageprovider.hpp"
|
#include "cachingimageprovider.hpp"
|
||||||
|
|
||||||
#include <qcryptographichash.h>
|
#include "imagecacher.hpp"
|
||||||
#include <qdir.h>
|
|
||||||
#include <qfile.h>
|
|
||||||
#include <qfileinfo.h>
|
#include <qfileinfo.h>
|
||||||
#include <qimage.h>
|
#include <qimage.h>
|
||||||
#include <qimagereader.h>
|
#include <qimagereader.h>
|
||||||
#include <qloggingcategory.h>
|
#include <qloggingcategory.h>
|
||||||
#include <qpainter.h>
|
|
||||||
#include <qrunnable.h>
|
#include <qrunnable.h>
|
||||||
#include <qsavefile.h>
|
|
||||||
#include <qthreadpool.h>
|
#include <qthreadpool.h>
|
||||||
|
|
||||||
Q_LOGGING_CATEGORY(lcCProv, "caelestia.images.cacheprovider", QtInfoMsg)
|
Q_LOGGING_CATEGORY(lcCProv, "caelestia.images.cacheprovider", QtInfoMsg)
|
||||||
|
|
@ -18,44 +15,9 @@ namespace caelestia::images {
|
||||||
|
|
||||||
namespace {
|
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 {
|
class CachingImageResponse final : public QQuickImageResponse, public QRunnable {
|
||||||
public:
|
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_id(id)
|
||||||
, m_requestedSize(requestedSize)
|
, m_requestedSize(requestedSize)
|
||||||
, m_fillMode(fillMode) {
|
, m_fillMode(fillMode) {
|
||||||
|
|
@ -87,87 +49,39 @@ private:
|
||||||
|
|
||||||
// Use original image if requested size is invalid
|
// Use original image if requested size is invalid
|
||||||
if (m_requestedSize.width() <= 0 || m_requestedSize.height() <= 0) {
|
if (m_requestedSize.width() <= 0 || m_requestedSize.height() <= 0) {
|
||||||
|
qCDebug(lcCProv).noquote() << "Given source size is invalid, returning original:" << path;
|
||||||
m_image = QImage(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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QString sha = sha256sum(path);
|
// Try to use cached image
|
||||||
if (sha.isEmpty()) {
|
const auto cachePath = ImageCacher::cachePathFor(path, m_requestedSize, m_fillMode);
|
||||||
m_error = QStringLiteral("Failed to hash: ") + path;
|
if (!cachePath.isEmpty()) {
|
||||||
return;
|
QImageReader cacheReader(cachePath);
|
||||||
|
if (cacheReader.canRead()) {
|
||||||
|
m_image = cacheReader.read();
|
||||||
|
if (!m_image.isNull())
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// clang-format off
|
// Schedule cache job (this call will return the original image, but later ones will use cache)
|
||||||
const QString filename = QStringLiteral("%1@%2x%3-%4.png")
|
ImageCacher::instance()->schedule(path, cachePath, m_requestedSize, m_fillMode);
|
||||||
.arg(sha).arg(m_requestedSize.width()).arg(m_requestedSize.height()).arg(fillSuffix(m_fillMode));
|
|
||||||
// clang-format on
|
|
||||||
const QString cache = cacheDir() + QLatin1Char('/') + filename;
|
|
||||||
|
|
||||||
// Check cache, if it already exists, set and return
|
m_image = QImage(path);
|
||||||
QImageReader cacheReader(cache);
|
if (m_image.isNull()) {
|
||||||
if (cacheReader.canRead()) {
|
m_error = QStringLiteral("Failed to decode source: ") + path;
|
||||||
m_image = cacheReader.read();
|
|
||||||
if (!m_image.isNull())
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
QImage image(path);
|
|
||||||
if (image.isNull()) {
|
|
||||||
m_error = QStringLiteral("Failed to decode: ") + path;
|
|
||||||
qCWarning(lcCProv).noquote() << m_error;
|
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;
|
QString m_id;
|
||||||
QSize m_requestedSize;
|
QSize m_requestedSize;
|
||||||
CachingImageProvider::FillMode m_fillMode;
|
ImageCacher::FillMode m_fillMode;
|
||||||
QImage m_image;
|
QImage m_image;
|
||||||
QString m_error;
|
QString m_error;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "imagecacher.hpp"
|
||||||
|
|
||||||
#include <qquickimageprovider.h>
|
#include <qquickimageprovider.h>
|
||||||
|
|
||||||
namespace caelestia::images {
|
namespace caelestia::images {
|
||||||
|
|
||||||
class CachingImageProvider : public QQuickAsyncImageProvider {
|
class CachingImageProvider : public QQuickAsyncImageProvider {
|
||||||
public:
|
public:
|
||||||
enum class FillMode {
|
using FillMode = ImageCacher::FillMode;
|
||||||
Crop,
|
|
||||||
Fit,
|
|
||||||
Stretch
|
|
||||||
};
|
|
||||||
|
|
||||||
explicit CachingImageProvider(FillMode fillMode);
|
explicit CachingImageProvider(FillMode fillMode);
|
||||||
|
|
||||||
|
|
|
||||||
159
plugin/src/Caelestia/Images/imagecacher.cpp
Normal file
159
plugin/src/Caelestia/Images/imagecacher.cpp
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
#include "imagecacher.hpp"
|
||||||
|
|
||||||
|
#include <qcryptographichash.h>
|
||||||
|
#include <qdir.h>
|
||||||
|
#include <qfile.h>
|
||||||
|
#include <qfileinfo.h>
|
||||||
|
#include <qimage.h>
|
||||||
|
#include <qloggingcategory.h>
|
||||||
|
#include <qmutex.h>
|
||||||
|
#include <qpainter.h>
|
||||||
|
#include <qsavefile.h>
|
||||||
|
#include <qthreadpool.h>
|
||||||
|
|
||||||
|
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
|
||||||
38
plugin/src/Caelestia/Images/imagecacher.hpp
Normal file
38
plugin/src/Caelestia/Images/imagecacher.hpp
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <qmutex.h>
|
||||||
|
#include <qobject.h>
|
||||||
|
#include <qset.h>
|
||||||
|
#include <qsize.h>
|
||||||
|
#include <qstring.h>
|
||||||
|
|
||||||
|
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<QString> m_inflight;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace caelestia::images
|
||||||
Loading…
Add table
Reference in a new issue