vlc/modules/gui/qt/widgets/native/roundimage.cpp

813 lines
24 KiB
C++

/*****************************************************************************
* roundimage.cpp: Custom widgets
****************************************************************************
* Copyright (C) 2021 the VideoLAN team
*
* Authors: Prince Gupta <guptaprince8832@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
*****************************************************************************/
#ifdef HAVE_CONFIG_H
# include "config.h"
#endif
#include "qt.hpp"
#include "roundimage.hpp"
#include "roundimage_p.hpp"
#include "util/asynctask.hpp"
#include "util/qsgroundedrectangularimagenode.hpp"
#include <qhashfunctions.h>
#include <QBuffer>
#include <QFile>
#include <QImage>
#include <QImageReader>
#include <QPainter>
#include <QPainterPath>
#include <QQuickWindow>
#include <QGuiApplication>
#include <QSGImageNode>
#include <QtQml>
#include <QQmlFile>
#ifdef QT_NETWORK_LIB
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#endif
bool operator ==(const ImageCacheKey &lhs, const ImageCacheKey &rhs)
{
return lhs.radius == rhs.radius && lhs.size == rhs.size && lhs.url == rhs.url;
}
uint qHash(const ImageCacheKey &key, uint seed)
{
QtPrivate::QHashCombine hash;
seed = hash(seed, key.url);
seed = hash(seed, key.size.width());
seed = hash(seed, key.size.height());
seed = hash(seed, key.radius);
return seed;
}
namespace
{
QImage prepareImage(const QSize &targetSize, const qreal radius, const QImage sourceImage)
{
if (qFuzzyIsNull(radius))
{
return sourceImage.scaled(targetSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
}
QImage target(targetSize, QImage::Format_ARGB32_Premultiplied);
if (target.isNull())
return target;
target.fill(Qt::transparent);
{
QPainter painter(&target);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
QPainterPath path;
path.addRoundedRect(0, 0, targetSize.width(), targetSize.height(), radius, radius);
painter.setClipPath(path);
// do PreserveAspectCrop
const auto imageSize = sourceImage.size();
const QPointF alignedCenteredTopLeft {(targetSize.width() - imageSize.width()) / 2.
, (targetSize.height() - imageSize.height()) / 2.};
painter.drawImage(QRectF {alignedCenteredTopLeft, imageSize}, sourceImage);
}
return target;
}
class ImageReader : public AsyncTask<QImage>
{
public:
/**
* @brief ImageReader
* @param device i/o source to read from, ImageReader doesn't take owner ship of the device
* parent must make sure availability of device through out the lifetime of this instance
*
* @param requestedSize only taken as hint, the Image is resized with PreserveAspectCrop
*
* @param radius
*/
ImageReader(QIODevice *device, QSize requestedSize, const qreal radius)
: device {device}
, requestedSize {requestedSize}
, radius {radius}
{
}
QString errorString() const { return errorStr; }
QImage execute() override
{
QImageReader reader;
reader.setDevice(device);
const QSize sourceSize = reader.size();
if (requestedSize.isValid())
reader.setScaledSize(sourceSize.scaled(requestedSize, Qt::KeepAspectRatioByExpanding));
auto img = reader.read();
errorStr = reader.errorString();
if (!errorStr.isEmpty())
img = prepareImage(requestedSize.isValid() ? requestedSize : img.size(), radius, img);
return img;
}
private:
QIODevice *device;
QSize requestedSize;
qreal radius;
QString errorStr;
};
class LocalImageResponse : public QQuickImageResponse
{
public:
LocalImageResponse(const QString &fileName, const QSize &requestedSize, const qreal radius)
{
auto file = new QFile(fileName);
reader.reset(new ImageReader(file, requestedSize, radius));
file->setParent(reader.get());
connect(reader.get(), &ImageReader::result, this, &LocalImageResponse::handleImageRead);
reader->start(*QThreadPool::globalInstance());
}
QQuickTextureFactory *textureFactory() const override
{
return result.isNull() ? nullptr : QQuickTextureFactory::textureFactoryForImage(result);
}
QString errorString() const override
{
return errorStr;
}
private:
void handleImageRead()
{
result = reader->takeResult();
errorStr = reader->errorString();
reader.reset();
emit finished();
}
QImage result;
TaskHandle<ImageReader> reader;
QString errorStr;
};
#ifdef QT_NETWORK_LIB
class NetworkImageResponse : public QQuickImageResponse
{
public:
/**
* @brief NetworkImageResponse
* @param reply - Network reply to read from, NetworkImageResponse takes ownership of this object
* @param requestedSize - passed to ImageReader class
* @param radius - passed to ImageReader class
*/
NetworkImageResponse(QNetworkReply *reply, QSize requestedSize, const qreal radius)
: reply {reply}
, requestedSize {requestedSize}
, radius {radius}
{
reply->setParent(this);
QObject::connect(reply, &QNetworkReply::finished
, this, &NetworkImageResponse::handleNetworkReplyFinished);
}
QQuickTextureFactory *textureFactory() const override
{
return result.isNull() ? nullptr : QQuickTextureFactory::textureFactoryForImage(result);
}
QString errorString() const override
{
return error;
}
void cancel() override
{
if (reply->isRunning())
reply->abort();
reader.reset();
}
private:
void handleNetworkReplyFinished()
{
if (reply->error() != QNetworkReply::NoError)
{
error = reply->errorString();
emit finished();
releaseNetworkReply();
return;
}
reader.reset(new ImageReader(reply, requestedSize, radius));
QObject::connect(reader.get(), &ImageReader::result, this, [this]()
{
result = reader->takeResult();
error = reader->errorString();
reader.reset();
releaseNetworkReply();
emit finished();
});
reader->start(*QThreadPool::globalInstance());
}
void releaseNetworkReply()
{
reply->deleteLater();
reply = nullptr;
}
QNetworkReply *reply;
QSize requestedSize;
qreal radius;
TaskHandle<ImageReader> reader;
QImage result;
QString error;
};
#endif
// adapts a given QQuickImageResponse to produce a image with radius
class ImageResponseRadiusAdaptor : public QQuickImageResponse
{
public:
ImageResponseRadiusAdaptor(QQuickImageResponse *response, const qreal radius) : response {response}, radius {radius}
{
response->setParent(this);
connect(response, &QQuickImageResponse::finished
, this, &ImageResponseRadiusAdaptor::handleResponseFinished);
}
QString errorString() const override { return errStr; }
QQuickTextureFactory *textureFactory() const override
{
return !result.isNull() ? QQuickTextureFactory::textureFactoryForImage(result) : nullptr;
}
private:
class RoundImageGenerator : public AsyncTask<QImage>
{
public:
RoundImageGenerator(const QImage &img, const qreal radius) : sourceImg {img}, radius{radius}
{
}
QImage execute() override
{
return prepareImage(sourceImg.size(), radius, sourceImg);
}
private:
QImage sourceImg;
qreal radius;
};
void handleResponseFinished()
{
errStr = response->errorString();
auto textureFactory = std::unique_ptr<QQuickTextureFactory>(response->textureFactory());
auto img = !textureFactory ? QImage {} : textureFactory->image();
if (!textureFactory || img.isNull())
{
// source response failed, signal to parent
emit finished();
return;
}
response->disconnect(this);
response->deleteLater();
response = nullptr;
generator.reset(new RoundImageGenerator(img, radius));
connect(generator.get(), &RoundImageGenerator::result
, this, &ImageResponseRadiusAdaptor::handleGeneratorFinished);
generator->start(*QThreadPool::globalInstance());
}
void handleGeneratorFinished()
{
result = generator->takeResult();
emit finished();
}
QQuickImageResponse *response;
QString errStr;
qreal radius;
QImage result;
TaskHandle<RoundImageGenerator> generator;
};
// global RoundImage cache
RoundImageCache g_imageCache = {};
}
// RoundImageCache
RoundImageCache::RoundImageCache()
: m_imageCache(32 * 1024 * 1024) // 32 MiB
{}
std::shared_ptr<RoundImageRequest> RoundImageCache::requestImage(const ImageCacheKey& key, qreal dpr, QQmlEngine *engine)
{
//do we already have a pending request?
auto it = m_pendingRequests.find(key);
if (it != m_pendingRequests.end())
{
std::shared_ptr<RoundImageRequest> request = it->second.lock();
if (request)
return request;
}
auto request = std::make_shared<RoundImageRequest>(key, dpr, engine);
if (request->getStatus() == RoundImage::Status::Error)
return {};
m_pendingRequests[key] = request;
return request;
}
void RoundImageCache::removeRequest(const ImageCacheKey& key)
{
m_pendingRequests.erase(key);
}
// RoundImageRequest
RoundImageRequest::RoundImageRequest(const ImageCacheKey& key, qreal dpr, QQmlEngine *engine)
: m_key(key)
, m_dpr(dpr)
{
m_imageResponse = getAsyncImageResponse(key.url, key.size, key.radius, engine);
if (m_imageResponse)
connect(m_imageResponse, &QQuickImageResponse::finished, this, &RoundImageRequest::handleImageResponseFinished);
else
m_status = RoundImage::Error;
}
RoundImageRequest::~RoundImageRequest()
{
if (m_imageResponse)
{
if (m_cancelOnDelete)
m_imageResponse->cancel();
m_imageResponse->deleteLater();
}
g_imageCache.removeRequest(m_key);
}
void RoundImageRequest::handleImageResponseFinished()
{
m_cancelOnDelete = false;
g_imageCache.removeRequest(m_key);
const QString error = m_imageResponse->errorString();
QImage image;
if (auto textureFactory = m_imageResponse->textureFactory())
{
image = textureFactory->image();
delete textureFactory;
}
if (image.isNull())
{
qDebug() << "failed to get image, error" << error << m_key.url;
m_status = RoundImage::Status::Error;
emit requestCompleted(m_status, {});
return;
}
image.setDevicePixelRatio(m_dpr);
g_imageCache.insert(m_key, new QImage(image), image.sizeInBytes());
emit requestCompleted(RoundImage::Status::Ready, image);
}
QQuickImageResponse* RoundImageRequest::getAsyncImageResponse(const QUrl &url, const QSize &requestedSize, const qreal radius, QQmlEngine *engine)
{
if (url.scheme() == QStringLiteral("image"))
{
auto provider = engine->imageProvider(url.host());
if (!provider)
return nullptr;
assert(provider->imageType() == QQmlImageProviderBase::ImageResponse);
const auto imageId = url.toString(QUrl::RemoveScheme | QUrl::RemoveAuthority).mid(1);
if (provider->imageType() == QQmlImageProviderBase::ImageResponse)
{
auto rawImageResponse = static_cast<QQuickAsyncImageProvider *>(provider)->requestImageResponse(imageId, requestedSize);
return new ImageResponseRadiusAdaptor(rawImageResponse, radius);
}
return nullptr;
}
else if (QQmlFile::isLocalFile(url))
{
return new LocalImageResponse(QQmlFile::urlToLocalFileOrQrc(url), requestedSize, radius);
}
#ifdef QT_NETWORK_LIB
else
{
QNetworkRequest request(url);
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
auto reply = engine->networkAccessManager()->get(request);
return new NetworkImageResponse(reply, requestedSize, radius);
}
#else
return nullptr;
#endif
}
// RoundImage
RoundImage::RoundImage(QQuickItem *parent) : QQuickItem {parent}
{
if (Q_LIKELY(qGuiApp))
setDPR(qGuiApp->devicePixelRatio());
connect(this, &QQuickItem::heightChanged, this, &RoundImage::regenerateRoundImage);
connect(this, &QQuickItem::widthChanged, this, &RoundImage::regenerateRoundImage);
connect(this, &QQuickItem::windowChanged, this, [this](const QQuickWindow* const window) {
if (window)
setDPR(window->devicePixelRatio());
else if (Q_LIKELY(qGuiApp))
setDPR(qGuiApp->devicePixelRatio());
});
connect(this, &QQuickItem::windowChanged, this, &RoundImage::adjustQSGCustomGeometry);
}
RoundImage::~RoundImage()
{
}
QSGNode *RoundImage::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *)
{
auto customImageNode = dynamic_cast<QSGRoundedRectangularImageNode*>(oldNode);
auto imageNode = dynamic_cast<QSGImageNode*>(oldNode);
if (Q_UNLIKELY(oldNode && ((m_QSGCustomGeometry && !customImageNode)
|| (!m_QSGCustomGeometry && !imageNode))))
{
// This must be extremely unlikely.
// Assigned to different window with different renderer?
delete oldNode;
oldNode = nullptr;
}
if (m_roundImage.isNull())
{
delete oldNode;
m_dirty = false;
return nullptr;
}
if (!oldNode)
{
if (m_QSGCustomGeometry)
{
customImageNode = new QSGRoundedRectangularImageNode;
}
else
{
assert(window());
imageNode = window()->createImageNode();
assert(imageNode);
imageNode->setOwnsTexture(true);
}
}
if (m_dirty)
{
m_dirty = false;
assert(window());
QQuickWindow::CreateTextureOptions flags = QQuickWindow::TextureCanUseAtlas;
if (!m_roundImage.hasAlphaChannel())
flags |= QQuickWindow::TextureIsOpaque;
if (std::unique_ptr<QSGTexture> texture { window()->createTextureFromImage(m_roundImage, flags) })
{
if (m_QSGCustomGeometry)
{
customImageNode->setTexture(std::move(texture));
}
else
{
// No need to delete the old texture manually as it is owned by the node.
imageNode->setTexture(texture.release());
}
}
else
{
qmlWarning(this) << "Could not generate texture from " << m_roundImage;
}
}
// Geometry:
if (m_QSGCustomGeometry)
{
customImageNode->setShape({boundingRect(), radius()});
customImageNode->setSmooth(smooth());
assert(customImageNode->geometry() && customImageNode->material());
return customImageNode;
}
else
{
imageNode->setRect(boundingRect());
imageNode->setFiltering(smooth() ? QSGTexture::Linear : QSGTexture::Nearest);
return imageNode;
}
}
void RoundImage::componentComplete()
{
QQuickItem::componentComplete();
if (!m_source.isEmpty())
regenerateRoundImage();
}
QUrl RoundImage::source() const
{
return m_source;
}
qreal RoundImage::radius() const
{
return m_radius;
}
RoundImage::Status RoundImage::status() const
{
return m_status;
}
void RoundImage::setSource(const QUrl& source)
{
if (m_source == source)
return;
m_source = source;
emit sourceChanged(m_source);
regenerateRoundImage();
}
void RoundImage::setRadius(qreal radius)
{
if (m_radius == radius)
return;
m_radius = radius;
emit radiusChanged(m_radius);
}
void RoundImage::itemChange(QQuickItem::ItemChange change, const QQuickItem::ItemChangeData &value)
{
if (change == QQuickItem::ItemDevicePixelRatioHasChanged)
setDPR(value.realValue);
QQuickItem::itemChange(change, value);
}
void RoundImage::setDPR(const qreal value)
{
if (m_dpr == value)
return;
m_dpr = value;
regenerateRoundImage();
}
void RoundImage::load()
{
m_enqueuedGeneration = false;
auto engine = qmlEngine(this);
if (!engine || m_source.isEmpty() || !size().isValid() || size().isEmpty())
return;
const qreal scaledWidth = this->width() * m_dpr;
const qreal scaledHeight = this->height() * m_dpr;
const qreal scaledRadius = m_QSGCustomGeometry ? 0.0 : (this->radius() * m_dpr);
const ImageCacheKey key {source(), QSizeF {scaledWidth, scaledHeight}.toSize(), scaledRadius};
if (auto image = g_imageCache.object(key)) // should only by called in mainthread
{
onRequestCompleted(Status::Ready, *image);
return;
}
m_activeImageResponse = g_imageCache.requestImage(key, m_dpr, engine);
if (!m_activeImageResponse)
{
onRequestCompleted(RoundImage::Error, {});
return;
}
connect(m_activeImageResponse.get(), &RoundImageRequest::requestCompleted, this, &RoundImage::onRequestCompleted);
//at this point m_activeImageResponse is either in Loading or Error status
onRequestCompleted(RoundImage::Loading, {});
}
void RoundImage::onRequestCompleted(Status status, const QImage& image)
{
setRoundImage(image);
switch (status)
{
case RoundImage::Error:
setStatus(Status::Error);
m_activeImageResponse.reset();
break;
case RoundImage::Ready:
{
if (image.isNull())
setStatus(Status::Error);
else
setStatus(Status::Ready);
m_activeImageResponse.reset();
break;
}
case RoundImage::Loading:
setStatus(Status::Loading);
break;
case RoundImage::Null:
//requests should not be yield this state
vlc_assert_unreachable();
}
}
void RoundImage::setRoundImage(QImage image)
{
if (m_roundImage.isNull() && image.isNull())
return;
m_dirty = true;
m_roundImage = image;
// remove old contents, setting ItemHasContent to false will
// inhibit updatePaintNode() call and old content will remain
if (image.isNull())
update();
setFlag(ItemHasContents, !image.isNull());
update();
}
void RoundImage::setStatus(const RoundImage::Status status)
{
if (status == m_status)
return;
m_status = status;
emit statusChanged();
}
void RoundImage::regenerateRoundImage()
{
if (!isComponentComplete() || m_enqueuedGeneration)
return;
setStatus(source().isEmpty() ? Status::Null : Status::Loading);
// remove old contents
setRoundImage({});
m_activeImageResponse.reset();
// use Qt::QueuedConnection to delay generation, so that dependent properties
// subsequent updates can be merged, f.e when VLCStyle.scale changes
m_enqueuedGeneration = true;
QMetaObject::invokeMethod(this, &RoundImage::load, Qt::QueuedConnection);
}
void RoundImage::adjustQSGCustomGeometry(const QQuickWindow* const window)
{
if (!window) return;
// No need to check if the scene graph is initialized according to docs.
const auto enableCustomGeometry = [this, window]() {
if (m_QSGCustomGeometry)
return;
// Favor custom geometry instead of clipping the image.
// This allows making the texture opaque, as long as
// source image is also opaque, for optimization
// purposes.
if (window->format().samples() != -1)
m_QSGCustomGeometry = true;
// No need to regenerate as transparent part will not
// matter. However, in order for the material to not
// require blending, a regeneration is necessary.
// We could force the material to not require blending
// for the outer transparent part which is not within the
// geometry, but then inherently transparent images would
// not be rendered correctly due to the alpha channel.
QMetaObject::invokeMethod(this,
&RoundImage::regenerateRoundImage,
Qt::QueuedConnection);
// It might be tempting to not regenerate the image on size
// change. However;
// If upscaled, we don't know if the image can be provided
// in a higher resolution.
// If downscaled, we would like to free some used memory.
// On the other hand, there is no need to regenerate the
// image when the radius changes. This behavior does not
// mean that the radius can be animated. Although possible,
// the custom geometry node is not designed to handle animations.
disconnect(this, &RoundImage::radiusChanged, this, &RoundImage::regenerateRoundImage);
connect(this, &RoundImage::radiusChanged, this, &QQuickItem::update);
};
const auto disableCustomGeometry = [this]() {
if (!m_QSGCustomGeometry)
return;
m_QSGCustomGeometry = false;
QMetaObject::invokeMethod(this,
&RoundImage::regenerateRoundImage,
Qt::QueuedConnection);
connect(this, &RoundImage::radiusChanged, this, &RoundImage::regenerateRoundImage);
disconnect(this, &RoundImage::radiusChanged, this, &QQuickItem::update);
};
if (window->rendererInterface()->graphicsApi() == QSGRendererInterface::GraphicsApi::OpenGL)
{
// Direct OpenGL, enable custom geometry:
enableCustomGeometry();
}
else
{
#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)
// QSG(Opaque)TextureMaterial supports Qt RHI,
// so there is no obstacle using custom geometry
// if Qt RHI is in use.
if (QSGRendererInterface::isApiRhiBased(window->rendererInterface()->graphicsApi()))
enableCustomGeometry();
else
disableCustomGeometry();
#else
// Qt RHI is introduced in Qt 5.14.
// QSG(Opaque)TextureMaterial does not support any graphics API other than OpenGL
// without the Qt RHI abstraction layer.
disableCustomGeometry();
#endif
}
}