diff --git a/CMakeLists.txt b/CMakeLists.txt index 146f2bb0..5561fc8d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -275,37 +275,40 @@ set(SRC_FILES src/ui/ThemeManager.cpp src/AvatarProvider.cpp + src/BlurhashProvider.cpp src/Cache.cpp src/ChatPage.cpp - src/CommunitiesListItem.cpp + src/ColorImageProvider.cpp src/CommunitiesList.cpp + src/CommunitiesListItem.cpp src/EventAccessors.cpp src/InviteeItem.cpp - src/LoginPage.cpp src/Logging.cpp + src/LoginPage.cpp src/MainWindow.cpp src/MatrixClient.cpp src/MxcImageProvider.cpp - src/ColorImageProvider.cpp - src/QuickSwitcher.cpp src/Olm.cpp + src/QuickSwitcher.cpp src/RegisterPage.cpp src/RoomInfoListItem.cpp src/RoomList.cpp src/SideBarActions.cpp src/Splitter.cpp - src/popups/SuggestionsPopup.cpp - src/popups/PopupItem.cpp - src/popups/ReplyPopup.cpp - src/popups/UserMentions.cpp src/TextInputWidget.cpp src/TopRoomBar.cpp src/TrayIcon.cpp - src/Utils.cpp src/UserInfoWidget.cpp src/UserSettingsPage.cpp + src/Utils.cpp src/WelcomePage.cpp + src/popups/PopupItem.cpp + src/popups/ReplyPopup.cpp + src/popups/SuggestionsPopup.cpp + src/popups/UserMentions.cpp src/main.cpp + + third_party/blurhash/blurhash.cpp ) @@ -333,7 +336,7 @@ if(USE_BUNDLED_MTXCLIENT) FetchContent_Declare( MatrixClient GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git - GIT_TAG 7fc1d357afaabb134cb6d9c593f94915973d31fa + GIT_TAG c1ccd6c6cdaead3ff1c2bf336b719ca45fee2d33 ) FetchContent_MakeAvailable(MatrixClient) else() @@ -478,28 +481,28 @@ qt5_wrap_cpp(MOC_HEADERS src/AvatarProvider.h src/Cache_p.h src/ChatPage.h - src/CommunitiesListItem.h src/CommunitiesList.h + src/CommunitiesListItem.h + src/InviteeItem.h src/LoginPage.h src/MainWindow.h src/MxcImageProvider.h - src/InviteeItem.h src/QuickSwitcher.h src/RegisterPage.h src/RoomInfoListItem.h src/RoomList.h src/SideBarActions.h src/Splitter.h - src/popups/SuggestionsPopup.h - src/popups/ReplyPopup.h - src/popups/PopupItem.h - src/popups/UserMentions.h src/TextInputWidget.h src/TopRoomBar.h src/TrayIcon.h src/UserInfoWidget.h src/UserSettingsPage.h src/WelcomePage.h + src/popups/PopupItem.h + src/popups/ReplyPopup.h + src/popups/SuggestionsPopup.h + src/popups/UserMentions.h ) # @@ -547,7 +550,7 @@ elseif(WIN32) else() target_link_libraries (nheko PRIVATE Qt5::DBus) endif() -target_include_directories(nheko PRIVATE src includes) +target_include_directories(nheko PRIVATE src includes third_party/blurhash) target_link_libraries(nheko PRIVATE MatrixClient::MatrixClient diff --git a/resources/qml/delegates/ImageMessage.qml b/resources/qml/delegates/ImageMessage.qml index cb05021d..62cae42c 100644 --- a/resources/qml/delegates/ImageMessage.qml +++ b/resources/qml/delegates/ImageMessage.qml @@ -11,6 +11,20 @@ Item { height: tooHigh ? timelineRoot.height / 2 : tempHeight width: tooHigh ? (timelineRoot.height / 2) / model.data.proportionalHeight : tempWidth + Image { + id: blurhash + anchors.fill: parent + visible: img.status != Image.Ready + + source: model.data.blurhash ? ("image://blurhash/" + model.data.blurhash) : ("image://colorimage/:/icons/icons/ui/do-not-disturb-rounded-sign@2x.png?"+colors.buttonText) + asynchronous: true + fillMode: Image.PreserveAspectFit + + + sourceSize.width: parent.width + sourceSize.height: parent.height + } + Image { id: img anchors.fill: parent diff --git a/src/BlurhashProvider.cpp b/src/BlurhashProvider.cpp new file mode 100644 index 00000000..a5530a98 --- /dev/null +++ b/src/BlurhashProvider.cpp @@ -0,0 +1,42 @@ +#include "BlurhashProvider.h" + +#include + +#include + +#include "blurhash.hpp" + +QImage +BlurhashProvider::requestImage(const QString &id, QSize *size, const QSize &requestedSize) +{ + QSize sz = requestedSize; + if (sz.width() < 1 || sz.height() < 1) + return QImage(); + + if (size) + *size = sz; + + auto decoded = blurhash::decode( + QUrl::fromPercentEncoding(id.toUtf8()).toStdString(), sz.width(), sz.height()); + if (decoded.image.empty()) { + *size = QSize(); + return QImage(); + } + + QImage image(sz, QImage::Format_RGB888); + + for (int y = 0; y < sz.height(); y++) { + for (int x = 0; x < sz.width(); x++) { + int base = (y * sz.width() + x) * 3; + image.setPixel(x, + y, + qRgb(decoded.image[base], + decoded.image[base + 1], + decoded.image[base + 2])); + } + } + + // std::copy(decoded.image.begin(), decoded.image.end(), image.bits()); + + return image; +} diff --git a/src/BlurhashProvider.h b/src/BlurhashProvider.h new file mode 100644 index 00000000..b05fff59 --- /dev/null +++ b/src/BlurhashProvider.h @@ -0,0 +1,11 @@ +#include + +class BlurhashProvider : public QQuickImageProvider +{ +public: + BlurhashProvider() + : QQuickImageProvider(QQuickImageProvider::Image) + {} + + QImage requestImage(const QString &id, QSize *size, const QSize &requestedSize) override; +}; diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp index 89bfd55a..6a7d984c 100644 --- a/src/ChatPage.cpp +++ b/src/ChatPage.cpp @@ -47,6 +47,8 @@ #include "popups/UserMentions.h" #include "timeline/TimelineViewManager.h" +#include "blurhash.hpp" + // TODO: Needs to be updated with an actual secret. static const std::string STORAGE_SECRET_KEY("secret"); @@ -324,9 +326,25 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent) } QSize dimensions; - if (mimeClass == "image") + QString blurhash; + if (mimeClass == "image") { dimensions = QImageReader(dev.data()).size(); + QImage img; + img.loadFromData(bin); + std::vector data; + for (int y = 0; y < img.height(); y++) { + for (int x = 0; x < img.width(); x++) { + auto p = img.pixel(x, y); + data.push_back(static_cast(qRed(p))); + data.push_back(static_cast(qGreen(p))); + data.push_back(static_cast(qBlue(p))); + } + } + blurhash = QString::fromStdString( + blurhash::encode(data.data(), img.width(), img.height(), 4, 3)); + } + http::client()->upload( payload, encryptedFile ? "application/octet-stream" : mime.name().toStdString(), @@ -339,6 +357,7 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent) mime = mime.name(), size = payload.size(), dimensions, + blurhash, related](const mtx::responses::ContentURI &res, mtx::http::RequestErr err) { if (err) { emit uploadFailed( @@ -358,6 +377,7 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent) mime, size, dimensions, + blurhash, related); }); }); @@ -366,37 +386,44 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent) text_input_->hideUploadSpinner(); emit showNotification(msg); }); - connect( - this, - &ChatPage::mediaUploaded, - this, - [this](QString roomid, - QString filename, - std::optional encryptedFile, - QString url, - QString mimeClass, - QString mime, - qint64 dsize, - QSize dimensions, - const std::optional &related) { - text_input_->hideUploadSpinner(); + connect(this, + &ChatPage::mediaUploaded, + this, + [this](QString roomid, + QString filename, + std::optional encryptedFile, + QString url, + QString mimeClass, + QString mime, + qint64 dsize, + QSize dimensions, + QString blurhash, + const std::optional &related) { + text_input_->hideUploadSpinner(); - if (encryptedFile) - encryptedFile->url = url.toStdString(); + if (encryptedFile) + encryptedFile->url = url.toStdString(); - if (mimeClass == "image") - view_manager_->queueImageMessage( - roomid, filename, encryptedFile, url, mime, dsize, dimensions, related); - else if (mimeClass == "audio") - view_manager_->queueAudioMessage( - roomid, filename, encryptedFile, url, mime, dsize, related); - else if (mimeClass == "video") - view_manager_->queueVideoMessage( - roomid, filename, encryptedFile, url, mime, dsize, related); - else - view_manager_->queueFileMessage( - roomid, filename, encryptedFile, url, mime, dsize, related); - }); + if (mimeClass == "image") + view_manager_->queueImageMessage(roomid, + filename, + encryptedFile, + url, + mime, + dsize, + dimensions, + blurhash, + related); + else if (mimeClass == "audio") + view_manager_->queueAudioMessage( + roomid, filename, encryptedFile, url, mime, dsize, related); + else if (mimeClass == "video") + view_manager_->queueVideoMessage( + roomid, filename, encryptedFile, url, mime, dsize, related); + else + view_manager_->queueFileMessage( + roomid, filename, encryptedFile, url, mime, dsize, related); + }); connect(room_list_, &RoomList::roomAvatarChanged, this, &ChatPage::updateTopBarAvatar); diff --git a/src/ChatPage.h b/src/ChatPage.h index 8e2e9192..02c19ba7 100644 --- a/src/ChatPage.h +++ b/src/ChatPage.h @@ -114,6 +114,7 @@ signals: const QString &mime, qint64 dsize, const QSize &dimensions, + const QString &blurhash, const std::optional &related); void contentLoaded(); diff --git a/src/EventAccessors.cpp b/src/EventAccessors.cpp index 20cdb63c..7f28eb46 100644 --- a/src/EventAccessors.cpp +++ b/src/EventAccessors.cpp @@ -134,6 +134,20 @@ struct EventThumbnailUrl } }; +struct EventBlurhash +{ + template + using blurhash_t = decltype(Content::info.blurhash); + template + std::string operator()(const mtx::events::Event &e) + { + if constexpr (is_detected::value) { + return e.content.info.blurhash; + } + return ""; + } +}; + struct EventFilename { template @@ -348,6 +362,11 @@ mtx::accessors::thumbnail_url(const mtx::events::collections::TimelineEvents &ev return std::visit(EventThumbnailUrl{}, event); } std::string +mtx::accessors::blurhash(const mtx::events::collections::TimelineEvents &event) +{ + return std::visit(EventBlurhash{}, event); +} +std::string mtx::accessors::mimetype(const mtx::events::collections::TimelineEvents &event) { return std::visit(EventMimeType{}, event); diff --git a/src/EventAccessors.h b/src/EventAccessors.h index cf79f68f..c9ac4d00 100644 --- a/src/EventAccessors.h +++ b/src/EventAccessors.h @@ -47,6 +47,8 @@ url(const mtx::events::collections::TimelineEvents &event); std::string thumbnail_url(const mtx::events::collections::TimelineEvents &event); std::string +blurhash(const mtx::events::collections::TimelineEvents &event); +std::string mimetype(const mtx::events::collections::TimelineEvents &event); std::string in_reply_to_event(const mtx::events::collections::TimelineEvents &event); diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp index cad39bc5..b187a67d 100644 --- a/src/timeline/TimelineModel.cpp +++ b/src/timeline/TimelineModel.cpp @@ -212,6 +212,7 @@ TimelineModel::roleNames() const {Timestamp, "timestamp"}, {Url, "url"}, {ThumbnailUrl, "thumbnailUrl"}, + {Blurhash, "blurhash"}, {Filename, "filename"}, {Filesize, "filesize"}, {MimeType, "mimetype"}, @@ -296,6 +297,8 @@ TimelineModel::data(const QString &id, int role) const return QVariant(QString::fromStdString(url(event))); case ThumbnailUrl: return QVariant(QString::fromStdString(thumbnail_url(event))); + case Blurhash: + return QVariant(QString::fromStdString(blurhash(event))); case Filename: return QVariant(QString::fromStdString(filename(event))); case Filesize: @@ -353,6 +356,7 @@ TimelineModel::data(const QString &id, int role) const m.insert(names[Timestamp], data(id, static_cast(Timestamp))); m.insert(names[Url], data(id, static_cast(Url))); m.insert(names[ThumbnailUrl], data(id, static_cast(ThumbnailUrl))); + m.insert(names[Blurhash], data(id, static_cast(Blurhash))); m.insert(names[Filename], data(id, static_cast(Filename))); m.insert(names[Filesize], data(id, static_cast(Filesize))); m.insert(names[MimeType], data(id, static_cast(MimeType))); diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h index f06de5d9..3dc1815f 100644 --- a/src/timeline/TimelineModel.h +++ b/src/timeline/TimelineModel.h @@ -142,6 +142,7 @@ public: Timestamp, Url, ThumbnailUrl, + Blurhash, Filename, Filesize, MimeType, diff --git a/src/timeline/TimelineViewManager.cpp b/src/timeline/TimelineViewManager.cpp index a3827501..44e26921 100644 --- a/src/timeline/TimelineViewManager.cpp +++ b/src/timeline/TimelineViewManager.cpp @@ -4,6 +4,7 @@ #include #include +#include "BlurhashProvider.h" #include "ChatPage.h" #include "ColorImageProvider.h" #include "DelegateChooser.h" @@ -69,6 +70,7 @@ TimelineViewManager::userColor(QString id, QColor background) TimelineViewManager::TimelineViewManager(QSharedPointer userSettings, QWidget *parent) : imgProvider(new MxcImageProvider()) , colorImgProvider(new ColorImageProvider()) + , blurhashProvider(new BlurhashProvider()) , settings(userSettings) { qmlRegisterUncreatableMetaObject(qml_mtx_events::staticMetaObject, @@ -99,6 +101,7 @@ TimelineViewManager::TimelineViewManager(QSharedPointer userSettin updateColorPalette(); view->engine()->addImageProvider("MxcImage", imgProvider); view->engine()->addImageProvider("colorimage", colorImgProvider); + view->engine()->addImageProvider("blurhash", blurhashProvider); view->setSource(QUrl("qrc:///qml/TimelineView.qml")); connect(dynamic_cast(parent), @@ -270,11 +273,13 @@ TimelineViewManager::queueImageMessage(const QString &roomid, const QString &mime, uint64_t dsize, const QSize &dimensions, + const QString &blurhash, const std::optional &related) { mtx::events::msg::Image image; image.info.mimetype = mime.toStdString(); image.info.size = dsize; + image.info.blurhash = blurhash.toStdString(); image.body = filename.toStdString(); image.url = url.toStdString(); image.info.h = dimensions.height(); diff --git a/src/timeline/TimelineViewManager.h b/src/timeline/TimelineViewManager.h index 338101c7..0c516e7f 100644 --- a/src/timeline/TimelineViewManager.h +++ b/src/timeline/TimelineViewManager.h @@ -14,6 +14,7 @@ #include "Utils.h" class MxcImageProvider; +class BlurhashProvider; class ColorImageProvider; class UserSettings; @@ -79,6 +80,7 @@ public slots: const QString &mime, uint64_t dsize, const QSize &dimensions, + const QString &blurhash, const std::optional &related); void queueFileMessage(const QString &roomid, const QString &filename, @@ -112,6 +114,7 @@ private: MxcImageProvider *imgProvider; ColorImageProvider *colorImgProvider; + BlurhashProvider *blurhashProvider; QHash> models; TimelineModel *timeline_ = nullptr; diff --git a/third_party/blurhash/LICENSE b/third_party/blurhash/LICENSE new file mode 100644 index 00000000..36b7cd93 --- /dev/null +++ b/third_party/blurhash/LICENSE @@ -0,0 +1,23 @@ +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/third_party/blurhash/blurhash.cpp b/third_party/blurhash/blurhash.cpp new file mode 100644 index 00000000..0ff6cb74 --- /dev/null +++ b/third_party/blurhash/blurhash.cpp @@ -0,0 +1,472 @@ +#include "blurhash.hpp" + +#include +#include +#include +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +#ifdef DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include +#endif + +using namespace std::literals; + +namespace { +constexpr std::array int_to_b83{ + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"}; + +std::string +leftPad(std::string str, size_t len) +{ + if (str.size() >= len) + return str; + return str.insert(0, len - str.size(), '0'); +} + +constexpr std::array b83_to_int = []() constexpr +{ + std::array a{}; + + for (auto &e : a) + e = -1; + + for (int i = 0; i < 83; i++) { + a[static_cast(int_to_b83[i])] = i; + } + + return a; +} +(); + +std::string +encode83(int value) +{ + std::string buffer; + + do { + buffer += int_to_b83[value % 83]; + } while ((value = value / 83)); + + std::reverse(buffer.begin(), buffer.end()); + return buffer; +} + +struct Components +{ + int x, y; +}; + +int +packComponents(const Components &c) +{ + return (c.x - 1) + (c.y - 1) * 9; +} + +Components +unpackComponents(int c) +{ + return {c % 9 + 1, c / 9 + 1}; +} + +int +decode83(std::string_view value) +{ + int temp = 0; + + for (char c : value) + if (b83_to_int[static_cast(c)] < 0) + throw std::invalid_argument("invalid character in blurhash"); + + for (char c : value) + temp = temp * 83 + b83_to_int[static_cast(c)]; + return temp; +} + +float +decodeMaxAC(int quantizedMaxAC) +{ + return (quantizedMaxAC + 1) / 166.; +} + +float +decodeMaxAC(std::string_view maxAC) +{ + assert(maxAC.size() == 1); + return decodeMaxAC(decode83(maxAC)); +} + +int +encodeMaxAC(float maxAC) +{ + return std::max(0, std::min(82, int(maxAC * 166 - 0.5))); +} + +float +srgbToLinear(int value) +{ + auto srgbToLinearF = [](float x) { + if (x <= 0.0f) + return 0.0f; + else if (x >= 1.0f) + return 1.0f; + else if (x < 0.04045f) + return x / 12.92f; + else + return std::pow((x + 0.055f) / 1.055f, 2.4f); + }; + + return srgbToLinearF(value / 255.f); +} + +int +linearToSrgb(float value) +{ + auto linearToSrgbF = [](float x) -> float { + if (x <= 0.0f) + return 0.0f; + else if (x >= 1.0f) + return 1.0f; + else if (x < 0.0031308f) + return x * 12.92f; + else + return std::pow(x, 1.0f / 2.4f) * 1.055f - 0.055f; + }; + + return int(linearToSrgbF(value) * 255.f + 0.5); +} + +struct Color +{ + float r, g, b; + + Color &operator*=(float scale) + { + r *= scale; + g *= scale; + b *= scale; + return *this; + } + friend Color operator*(Color lhs, float rhs) { return (lhs *= rhs); } + Color &operator/=(float scale) + { + r /= scale; + g /= scale; + b /= scale; + return *this; + } + Color &operator+=(const Color &rhs) + { + r += rhs.r; + g += rhs.g; + b += rhs.b; + return *this; + } +}; + +Color +decodeDC(int value) +{ + const int intR = value >> 16; + const int intG = (value >> 8) & 255; + const int intB = value & 255; + return {srgbToLinear(intR), srgbToLinear(intG), srgbToLinear(intB)}; +} + +Color +decodeDC(std::string_view value) +{ + assert(value.size() == 4); + return decodeDC(decode83(value)); +} + +int +encodeDC(const Color &c) +{ + return (linearToSrgb(c.r) << 16) + (linearToSrgb(c.g) << 8) + linearToSrgb(c.b); +} + +float +signPow(float value, float exp) +{ + return std::copysign(std::pow(std::abs(value), exp), value); +} + +int +encodeAC(const Color &c, float maximumValue) +{ + auto quantR = + int(std::max(0., std::min(18., std::floor(signPow(c.r / maximumValue, 0.5) * 9 + 9.5)))); + auto quantG = + int(std::max(0., std::min(18., std::floor(signPow(c.g / maximumValue, 0.5) * 9 + 9.5)))); + auto quantB = + int(std::max(0., std::min(18., std::floor(signPow(c.b / maximumValue, 0.5) * 9 + 9.5)))); + + return quantR * 19 * 19 + quantG * 19 + quantB; +} + +Color +decodeAC(int value, float maximumValue) +{ + auto quantR = value / (19 * 19); + auto quantG = (value / 19) % 19; + auto quantB = value % 19; + + return {signPow((float(quantR) - 9) / 9, 2) * maximumValue, + signPow((float(quantG) - 9) / 9, 2) * maximumValue, + signPow((float(quantB) - 9) / 9, 2) * maximumValue}; +} + +Color +decodeAC(std::string_view value, float maximumValue) +{ + return decodeAC(decode83(value), maximumValue); +} + +Color +multiplyBasisFunction(Components components, int width, int height, unsigned char *pixels) +{ + Color c{}; + float normalisation = (components.x == 0 && components.y == 0) ? 1 : 2; + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + float basis = std::cos(M_PI * components.x * x / float(width)) * + std::cos(M_PI * components.y * y / float(height)); + c.r += basis * srgbToLinear(pixels[3 * x + 0 + y * width * 3]); + c.g += basis * srgbToLinear(pixels[3 * x + 1 + y * width * 3]); + c.b += basis * srgbToLinear(pixels[3 * x + 2 + y * width * 3]); + } + } + + float scale = normalisation / (width * height); + c *= scale; + return c; +} +} + +namespace blurhash { +Image +decode(std::string_view blurhash, size_t width, size_t height) +{ + Image i{}; + + if (blurhash.size() < 10) + return i; + + Components components{}; + std::vector values; + try { + components = unpackComponents(decode83(blurhash.substr(0, 1))); + + if (components.x < 1 || components.y < 1 || + blurhash.size() != size_t(1 + 1 + 4 + (components.x * components.y - 1) * 2)) + return {}; + + auto maxAC = decodeMaxAC(blurhash.substr(1, 1)); + Color average = decodeDC(blurhash.substr(2, 4)); + + values.push_back(average); + for (size_t c = 6; c < blurhash.size(); c += 2) + values.push_back(decodeAC(blurhash.substr(c, 2), maxAC)); + } catch (std::invalid_argument &) { + return {}; + } + + i.image.reserve(height * width * 3); + + for (size_t y = 0; y < height; y++) { + for (size_t x = 0; x < width; x++) { + Color c{}; + + for (size_t nx = 0; nx < size_t(components.x); nx++) { + for (size_t ny = 0; ny < size_t(components.y); ny++) { + float basis = + std::cos(M_PI * float(x) * float(nx) / float(width)) * + std::cos(M_PI * float(y) * float(ny) / float(height)); + c += values[nx + ny * components.x] * basis; + } + } + + i.image.push_back(static_cast(linearToSrgb(c.r))); + i.image.push_back(static_cast(linearToSrgb(c.g))); + i.image.push_back(static_cast(linearToSrgb(c.b))); + } + } + + i.height = height; + i.width = width; + + return i; +} + +std::string +encode(unsigned char *image, size_t width, size_t height, int components_x, int components_y) +{ + if (width < 1 || height < 1 || components_x < 1 || components_x > 9 || components_y < 1 || + components_y > 9 || !image) + return ""; + + std::vector factors; + factors.reserve(components_x * components_y); + for (int y = 0; y < components_y; y++) { + for (int x = 0; x < components_x; x++) { + factors.push_back(multiplyBasisFunction({x, y}, width, height, image)); + } + } + + assert(factors.size() > 0); + + auto dc = factors.front(); + factors.erase(factors.begin()); + + std::string h; + + h += leftPad(encode83(packComponents({components_x, components_y})), 1); + + float maximumValue; + if (!factors.empty()) { + float actualMaximumValue = 0; + for (auto ac : factors) { + actualMaximumValue = std::max({ + std::abs(ac.r), + std::abs(ac.g), + std::abs(ac.b), + actualMaximumValue, + }); + } + + int quantisedMaximumValue = encodeMaxAC(actualMaximumValue); + maximumValue = ((float)quantisedMaximumValue + 1) / 166; + h += leftPad(encode83(quantisedMaximumValue), 1); + } else { + maximumValue = 1; + h += leftPad(encode83(0), 1); + } + + h += leftPad(encode83(encodeDC(dc)), 4); + + for (auto ac : factors) + h += leftPad(encode83(encodeAC(ac, maximumValue)), 2); + + return h; +} +} + +#ifdef DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +TEST_CASE("component packing") +{ + for (int i = 0; i < 9 * 9; i++) + CHECK(packComponents(unpackComponents(i)) == i); +} + +TEST_CASE("encode83") +{ + CHECK(encode83(0) == "0"); + + CHECK(encode83(packComponents({4, 3})) == "L"); + CHECK(encode83(packComponents({4, 4})) == "U"); + CHECK(encode83(packComponents({8, 4})) == "Y"); + CHECK(encode83(packComponents({2, 1})) == "1"); +} + +TEST_CASE("decode83") +{ + CHECK(packComponents({4, 3}) == decode83("L")); + CHECK(packComponents({4, 4}) == decode83("U")); + CHECK(packComponents({8, 4}) == decode83("Y")); + CHECK(packComponents({2, 1}) == decode83("1")); +} + +TEST_CASE("maxAC") +{ + for (int i = 0; i < 83; i++) + CHECK(encodeMaxAC(decodeMaxAC(i)) == i); + + CHECK(std::abs(decodeMaxAC("l"sv) - 0.289157f) < 0.00001f); +} + +TEST_CASE("DC") +{ + CHECK(encode83(encodeDC(decodeDC("MF%n"))) == "MF%n"sv); + CHECK(encode83(encodeDC(decodeDC("HV6n"))) == "HV6n"sv); + CHECK(encode83(encodeDC(decodeDC("F5]+"))) == "F5]+"sv); + CHECK(encode83(encodeDC(decodeDC("Pj0^"))) == "Pj0^"sv); + CHECK(encode83(encodeDC(decodeDC("O2?U"))) == "O2?U"sv); +} + +TEST_CASE("AC") +{ + auto h = "00%#MwS|WCWEM{R*bbWBbH"sv; + for (size_t i = 0; i < h.size(); i += 2) { + auto s = h.substr(i, 2); + const auto maxAC = 0.289157f; + CHECK(leftPad(encode83(encodeAC(decodeAC(decode83(s), maxAC), maxAC)), 2) == s); + } +} + +TEST_CASE("decode") +{ + blurhash::Image i1 = blurhash::decode("LEHV6nWB2yk8pyoJadR*.7kCMdnj", 360, 200); + CHECK(i1.width == 360); + CHECK(i1.height == 200); + CHECK(i1.image.size() == i1.height * i1.width * 3); + CHECK(i1.image[0] == 135); + CHECK(i1.image[1] == 164); + CHECK(i1.image[2] == 177); + CHECK(i1.image[10000] == 173); + CHECK(i1.image[10001] == 176); + CHECK(i1.image[10002] == 163); + // stbi_write_bmp("test.bmp", i1.width, i1.height, 3, (void *)i1.image.data()); + + i1 = blurhash::decode("LGF5]+Yk^6#M@-5c,1J5@[or[Q6.", 360, 200); + CHECK(i1.width == 360); + CHECK(i1.height == 200); + CHECK(i1.image.size() == i1.height * i1.width * 3); + // stbi_write_bmp("test2.bmp", i1.width, i1.height, 3, (void *)i1.image.data()); + + // invalid inputs + i1 = blurhash::decode(" LGF5]+Yk^6#M@-5c,1J5@[or[Q6.", 360, 200); + CHECK(i1.width == 0); + CHECK(i1.height == 0); + CHECK(i1.image.size() == 0); + i1 = blurhash::decode(" LGF5]+Yk^6#M@-5c,1J5@[or[Q6.", 360, 200); + CHECK(i1.width == 0); + CHECK(i1.height == 0); + CHECK(i1.image.size() == 0); + + i1 = blurhash::decode("LGF5]+Yk^6# M@-5c,1J5@[or[Q6.", 360, 200); + CHECK(i1.width == 0); + CHECK(i1.height == 0); + CHECK(i1.image.size() == 0); + i1 = blurhash::decode("LGF5]+Yk^6# M@-5c,1J5@[or[Q6.", 360, 200); + CHECK(i1.width == 0); + CHECK(i1.height == 0); + CHECK(i1.image.size() == 0); + + i1 = blurhash::decode("LGF5]+Yk^6# @-5c,1J5@[or[Q6.", 360, 200); + CHECK(i1.width == 0); + CHECK(i1.height == 0); + CHECK(i1.image.size() == 0); + i1 = blurhash::decode(" GF5]+Yk^6#M@-5c,1J5@[or[Q6.", 360, 200); + CHECK(i1.width == 0); + CHECK(i1.height == 0); + CHECK(i1.image.size() == 0); +} + +TEST_CASE("encode") +{ + CHECK(blurhash::encode(nullptr, 360, 200, 4, 3) == ""); + + std::vector black(360 * 200 * 3, 0); + CHECK(blurhash::encode(black.data(), 0, 200, 4, 3) == ""); + CHECK(blurhash::encode(black.data(), 360, 0, 4, 3) == ""); + CHECK(blurhash::encode(black.data(), 360, 200, 0, 3) == ""); + CHECK(blurhash::encode(black.data(), 360, 200, 4, 0) == ""); + CHECK(blurhash::encode(black.data(), 360, 200, 4, 3) == "L00000fQfQfQfQfQfQfQfQfQfQfQ"); +} +#endif diff --git a/third_party/blurhash/blurhash.hpp b/third_party/blurhash/blurhash.hpp new file mode 100644 index 00000000..5077f0d5 --- /dev/null +++ b/third_party/blurhash/blurhash.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +namespace blurhash { +struct Image +{ + size_t width, height; + std::vector image; // pixels rgb +}; + +// Decode a blurhash to an image with size width*height +Image +decode(std::string_view blurhash, size_t width, size_t height); + +// Encode an image of rgb pixels (without padding) with size width*height into a blurhash with x*y +// components +std::string +encode(unsigned char *image, size_t width, size_t height, int x, int y); +}