summary refs log tree commit diff
diff options
context:
space:
mode:
authorNicolas Werner <nicolas.werner@hotmail.de>2020-03-01 19:55:43 +0100
committerNicolas Werner <nicolas.werner@hotmail.de>2020-03-03 02:34:26 +0100
commit0fc98b26920961f4cf9002f0413684d9c18671cc (patch)
tree150152fdeaf1398a64618d77fc9ada6d48bed3ec
parentFix avatar layering in room list (diff)
downloadnheko-0fc98b26920961f4cf9002f0413684d9c18671cc.tar.xz
Experimental blurhash implementation (MXC2448)
-rw-r--r--CMakeLists.txt37
-rw-r--r--resources/qml/delegates/ImageMessage.qml14
-rw-r--r--src/BlurhashProvider.cpp42
-rw-r--r--src/BlurhashProvider.h11
-rw-r--r--src/ChatPage.cpp91
-rw-r--r--src/ChatPage.h1
-rw-r--r--src/EventAccessors.cpp19
-rw-r--r--src/EventAccessors.h2
-rw-r--r--src/timeline/TimelineModel.cpp4
-rw-r--r--src/timeline/TimelineModel.h1
-rw-r--r--src/timeline/TimelineViewManager.cpp5
-rw-r--r--src/timeline/TimelineViewManager.h3
-rw-r--r--third_party/blurhash/LICENSE23
-rw-r--r--third_party/blurhash/blurhash.cpp472
-rw-r--r--third_party/blurhash/blurhash.hpp22
15 files changed, 698 insertions, 49 deletions
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
@@ -12,6 +12,20 @@ Item {
 	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 <algorithm>
+
+#include <QUrl>
+
+#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 <QQuickImageProvider>
+
+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> 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<unsigned char> 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<unsigned char>(qRed(p)));
+                                          data.push_back(static_cast<unsigned char>(qGreen(p)));
+                                          data.push_back(static_cast<unsigned char>(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> 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> userSettings, QWidget *parent)
                                                mime,
                                                size,
                                                dimensions,
+                                               blurhash,
                                                related);
                     });
           });
@@ -366,37 +386,44 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
                 text_input_->hideUploadSpinner();
                 emit showNotification(msg);
         });
-        connect(
-          this,
-          &ChatPage::mediaUploaded,
-          this,
-          [this](QString roomid,
-                 QString filename,
-                 std::optional<mtx::crypto::EncryptedFile> encryptedFile,
-                 QString url,
-                 QString mimeClass,
-                 QString mime,
-                 qint64 dsize,
-                 QSize dimensions,
-                 const std::optional<RelatedInfo> &related) {
-                  text_input_->hideUploadSpinner();
-
-                  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);
-          });
+        connect(this,
+                &ChatPage::mediaUploaded,
+                this,
+                [this](QString roomid,
+                       QString filename,
+                       std::optional<mtx::crypto::EncryptedFile> encryptedFile,
+                       QString url,
+                       QString mimeClass,
+                       QString mime,
+                       qint64 dsize,
+                       QSize dimensions,
+                       QString blurhash,
+                       const std::optional<RelatedInfo> &related) {
+                        text_input_->hideUploadSpinner();
+
+                        if (encryptedFile)
+                                encryptedFile->url = url.toStdString();
+
+                        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<RelatedInfo> &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<class Content>
+        using blurhash_t = decltype(Content::info.blurhash);
+        template<class T>
+        std::string operator()(const mtx::events::Event<T> &e)
+        {
+                if constexpr (is_detected<blurhash_t, T>::value) {
+                        return e.content.info.blurhash;
+                }
+                return "";
+        }
+};
+
 struct EventFilename
 {
         template<class T>
@@ -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<int>(Timestamp)));
                 m.insert(names[Url], data(id, static_cast<int>(Url)));
                 m.insert(names[ThumbnailUrl], data(id, static_cast<int>(ThumbnailUrl)));
+                m.insert(names[Blurhash], data(id, static_cast<int>(Blurhash)));
                 m.insert(names[Filename], data(id, static_cast<int>(Filename)));
                 m.insert(names[Filesize], data(id, static_cast<int>(Filesize)));
                 m.insert(names[MimeType], data(id, static_cast<int>(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 <QPalette>
 #include <QQmlContext>
 
+#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> 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<UserSettings> 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<ChatPage *>(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<RelatedInfo> &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<RelatedInfo> &related);
         void queueFileMessage(const QString &roomid,
                               const QString &filename,
@@ -112,6 +114,7 @@ private:
 
         MxcImageProvider *imgProvider;
         ColorImageProvider *colorImgProvider;
+        BlurhashProvider *blurhashProvider;
 
         QHash<QString, QSharedPointer<TimelineModel>> 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 <algorithm>
+#include <array>
+#include <cassert>
+#include <cmath>
+#include <stdexcept>
+
+#ifndef M_PI
+#define M_PI 3.14159265358979323846
+#endif
+
+#ifdef DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+#include <doctest.h>
+#endif
+
+using namespace std::literals;
+
+namespace {
+constexpr std::array<char, 84> 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<int, 255> b83_to_int = []() constexpr
+{
+        std::array<int, 255> a{};
+
+        for (auto &e : a)
+                e = -1;
+
+        for (int i = 0; i < 83; i++) {
+                a[static_cast<unsigned char>(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<unsigned char>(c)] < 0)
+                        throw std::invalid_argument("invalid character in blurhash");
+
+        for (char c : value)
+                temp = temp * 83 + b83_to_int[static_cast<unsigned char>(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<Color> 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<unsigned char>(linearToSrgb(c.r)));
+                        i.image.push_back(static_cast<unsigned char>(linearToSrgb(c.g)));
+                        i.image.push_back(static_cast<unsigned char>(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<Color> 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<unsigned char> 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 <string>
+#include <string_view>
+#include <vector>
+
+namespace blurhash {
+struct Image
+{
+        size_t width, height;
+        std::vector<unsigned char> 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);
+}