From b0acb285c1d677a254e69fe303ece8212cdacfb7 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 03:33:50 +0100 Subject: [PATCH] Bound TGS decoding and keep invalid icons out of animation --- tools/chatgui/src/animtimer.cpp | 1 + tools/chatgui/src/animtimer.h | 28 +++--- tools/chatgui/src/lottieicon.cpp | 102 ++++++++++++-------- tools/chatgui/src/lottieicon.h | 30 +++--- tools/chatgui/tests/test_emoji_playback.cpp | 71 +++++++++++++- 5 files changed, 166 insertions(+), 66 deletions(-) diff --git a/tools/chatgui/src/animtimer.cpp b/tools/chatgui/src/animtimer.cpp index 611fae87..8f8f72c5 100644 --- a/tools/chatgui/src/animtimer.cpp +++ b/tools/chatgui/src/animtimer.cpp @@ -20,6 +20,7 @@ void AnimTimer::shutdown() { } void AnimTimer::registerIcon(const QString &key, LottieIcon *icon) { + if (!icon || !icon->isValid()) { GUI_WARN("AnimTimer: invalid icon registration key=%s", qPrintable(key)); return; } m_icons.insert(key, icon); } diff --git a/tools/chatgui/src/animtimer.h b/tools/chatgui/src/animtimer.h index ce6ef822..78e7cbf0 100644 --- a/tools/chatgui/src/animtimer.h +++ b/tools/chatgui/src/animtimer.h @@ -1,5 +1,9 @@ /* Общий GUI-таймер анимированных emoji: регистрирует LottieIcon и выдаёт ticked. - * play запускает минимум PlaybackMs; shutdown останавливает таймер и очищает регистрацию. */ + * Все операции в GUI-потоке с живым QCoreApplication. Singleton владеет только QTimer; + * значки заимствованы: до их удаления вызвать unregisterIcon или shutdown. + * Один общий tick каждые 33 мс двигает каждый значок на кадр независимо от metadata FPS. + * play запускает минимум PlaybackMs; после дедлайна каждый значок останавливается + * отдельно на кадре 0, затем таймер останавливается. shutdown не удаляет значки. */ #pragma once #include @@ -12,27 +16,27 @@ class LottieIcon; class AnimTimer : public QObject { Q_OBJECT public: - static AnimTimer *instance(); + static AnimTimer *instance(); // статический объект на весь процесс, вызывающий не удаляет - void registerIcon(const QString &key, LottieIcon *icon); - void unregisterIcon(const QString &key); - LottieIcon *icon(const QString &key) const; - void logRegisteredIcons() const; + void registerIcon(const QString &key, LottieIcon *icon); // только валидный icon; существующий key заменяет указатель без delete + void unregisterIcon(const QString &key); // отсутствующий key допустим, объект не удаляется + LottieIcon *icon(const QString &key) const; // заимствованный до замены/удаления владельцем; NULL при отсутствии + void logRegisteredIcons() const; // накопленные parser WARN и DEBUG-метаданные в general // Наведение запускает минимум две секунды воспроизведения, затем каждый значок завершает свой цикл. static constexpr int PlaybackMs = 2000; - void play(); - static void shutdown(); + void play(); // каждый вызов заново отсчитывает PlaybackMs, без сброса текущих кадров; пустой реестр даёт WARN + static void shutdown(); // stop, сброс дедлайна/реестра; следующий registerIcon/play допустим signals: - void ticked(); + void ticked(); // после изменения кадров, включая последний tick с остановкой всех на 0 private slots: void onTimer(); private: AnimTimer(); - QTimer m_timer; - QHash m_icons; - QElapsedTimer m_playback; + QTimer m_timer; // период 33 мс; закончивший цикл icon после дедлайна больше не продвигается + QHash m_icons; // не владеет значками, доступ только в GUI + QElapsedTimer m_playback; // время с последнего play, invalid после shutdown/завершения }; diff --git a/tools/chatgui/src/lottieicon.cpp b/tools/chatgui/src/lottieicon.cpp index 67188953..fa748227 100644 --- a/tools/chatgui/src/lottieicon.cpp +++ b/tools/chatgui/src/lottieicon.cpp @@ -2,7 +2,8 @@ #include "debug_ui.h" #include #include -#include +#include +#include #ifdef RLOTTIE_HAS_WARNING_CALLBACK static thread_local QStringList *lottieLoadWarnings = nullptr; @@ -13,42 +14,57 @@ static void logLottieWarning(const char *message) { } #endif -static QByteArray unpackGzip(const QByteArray &data) { - if (data.isEmpty()) return {}; +/* Весь вход уже доступен: Z_BUF_ERROR/отсутствие прогресса означают повреждение, ждать больше нечего. */ +static QByteArray unpackGzip(const QByteArray &data, const QString& key) { + if (data.isEmpty() || data.size() > std::numeric_limits::max()) { + GUI_ERROR("LottieIcon: invalid gzip input path=%s bytes=%lld", qPrintable(key), (long long)data.size()); + return {}; + } z_stream strm = {}; int ret = inflateInit2(&strm, 16 + MAX_WBITS); - if (ret != Z_OK) return {}; + if (ret != Z_OK) { GUI_ERROR("LottieIcon: gzip init failed path=%s zlib=%d", qPrintable(key), ret); return {}; } strm.avail_in = (uInt)data.size(); - strm.next_in = (Bytef *)data.constData(); + strm.next_in = reinterpret_cast(const_cast(data.constData())); QByteArray result; - result.resize(data.size() * 3); - size_t total = 0; - do { - size_t remain = (size_t)result.size() - total; - if (remain < 4096) { - result.resize(result.size() * 2); - remain = (size_t)result.size() - total; - } - strm.avail_out = (uInt)remain; - strm.next_out = (Bytef *)result.data() + total; + std::array chunk; + bool complete = false; + for (;;) { + const auto before = strm.total_in; + strm.avail_out = chunk.size(); + strm.next_out = reinterpret_cast(chunk.data()); ret = inflate(&strm, Z_NO_FLUSH); - if (ret == Z_STREAM_ERROR || ret == Z_DATA_ERROR) { - inflateEnd(&strm); - return {}; + const int produced = int(chunk.size() - strm.avail_out); + if (ret != Z_OK && ret != Z_STREAM_END) { + const char* reason = ret == Z_BUF_ERROR && !strm.avail_in ? "truncated gzip" : strm.msg ? strm.msg : zError(ret); + GUI_ERROR("LottieIcon: gzip decode failed path=%s zlib=%d input=%lu/%lld output=%lld reason=%s", + qPrintable(key), ret, strm.total_in, (long long)data.size(), (long long)result.size(), reason); + break; } - total += remain - strm.avail_out; - } while (ret != Z_STREAM_END); + if (result.size() > LottieIcon::MaxJsonBytes - produced) { + GUI_ERROR("LottieIcon: JSON size limit exceeded path=%s limit=%d", qPrintable(key), LottieIcon::MaxJsonBytes); + break; + } + result.append(chunk.data(), produced); + if (ret == Z_STREAM_END) { + complete = !result.isEmpty() && strm.avail_in == 0; + if (!complete) GUI_ERROR("LottieIcon: empty JSON or trailing gzip data path=%s trailing=%u", qPrintable(key), strm.avail_in); + break; + } + if (strm.total_in == before && !produced) { + GUI_ERROR("LottieIcon: gzip made no progress path=%s remaining=%u", qPrintable(key), strm.avail_in); + break; + } + } inflateEnd(&strm); - result.resize((int)total); - return result; + return complete ? result : QByteArray(); } LottieIcon::LottieIcon(const QByteArray &tgsData, const QString &key) : m_key(key) { #ifdef RLOTTIE_HAS_WARNING_CALLBACK lottie_set_warning_callback(logLottieWarning); #endif - m_tgsSize = (int)tgsData.size(); - QByteArray json = unpackGzip(tgsData); + if (tgsData.size() <= std::numeric_limits::max()) m_tgsSize = int(tgsData.size()); + QByteArray json = unpackGzip(tgsData, key); if (json.isEmpty()) return; m_jsonData = json; @@ -62,13 +78,20 @@ LottieIcon::LottieIcon(const QByteArray &tgsData, const QString &key) : m_key(ke #ifdef RLOTTIE_HAS_WARNING_CALLBACK lottieLoadWarnings = previousWarnings; #endif - if (!m_anim) return; + if (!m_anim) { logWarnings(); GUI_ERROR("LottieIcon: JSON animation load failed path=%s", qPrintable(m_key)); return; } lottie_animation_get_size(m_anim, &m_w, &m_h); - m_totalFrames = (int)lottie_animation_get_totalframe(m_anim); + const size_t frames = lottie_animation_get_totalframe(m_anim); + if (!frames || frames > size_t(std::numeric_limits::max()) || !m_w || !m_h || + m_w > size_t(std::numeric_limits::max()) || m_h > size_t(std::numeric_limits::max())) { + GUI_ERROR("LottieIcon: invalid metadata path=%s frames=%zu size=%zux%zu", qPrintable(m_key), frames, m_w, m_h); + return; + } + m_totalFrames = int(frames); m_frameRate = lottie_animation_get_framerate(m_anim); - if (m_totalFrames <= 0) m_totalFrames = 1; m_valid = true; + GUI_DEBUG("LottieIcon: loaded path=%s gzip=%d json=%lld frames=%d size=%zux%zu fps=%.2f", + qPrintable(m_key), m_tgsSize, (long long)m_jsonData.size(), m_totalFrames, m_w, m_h, m_frameRate); } LottieIcon::~LottieIcon() { @@ -83,17 +106,20 @@ QImage LottieIcon::renderFrame(int frameNum, QSize targetSize) const { if (!m_valid || targetSize.width() <= 0 || targetSize.height() <= 0) return QImage(); - int w = targetSize.width(); - int h = targetSize.height(); - size_t stride = (size_t)w * 4; - std::vector buffer((size_t)w * (size_t)h); - + QImage image(targetSize, QImage::Format_ARGB32_Premultiplied); + if (image.isNull()) { + GUI_ERROR("LottieIcon: image allocation failed path=%s size=%dx%d", qPrintable(m_key), targetSize.width(), targetSize.height()); + return {}; + } + image.fill(Qt::transparent); int frame = frameNum % m_totalFrames; - lottie_animation_render(m_anim, (size_t)frame, buffer.data(), - (size_t)w, (size_t)h, stride); + if (frame < 0) frame += m_totalFrames; + lottie_animation_render(m_anim, size_t(frame), reinterpret_cast(image.bits()), + size_t(image.width()), size_t(image.height()), size_t(image.bytesPerLine())); + return image; +} - QImage img(w, h, QImage::Format_ARGB32_Premultiplied); - for (int y = 0; y < h; y++) - std::memcpy(img.scanLine(y), &buffer[(size_t)y * (size_t)w], stride); - return img; +void LottieIcon::advanceFrame() { + if (!m_valid) { GUI_WARN("LottieIcon: advance requested for invalid animation path=%s", qPrintable(m_key)); return; } + m_currentFrame = (m_currentFrame + 1) % m_totalFrames; } diff --git a/tools/chatgui/src/lottieicon.h b/tools/chatgui/src/lottieicon.h index ac5e40cd..677d59e6 100644 --- a/tools/chatgui/src/lottieicon.h +++ b/tools/chatgui/src/lottieicon.h @@ -1,5 +1,8 @@ -/* Обёртка rlottie для TGS: распаковывает анимацию, хранит текущий кадр и рисует QImage. - * Воспроизведение координирует AnimTimer; объект освобождает свой animation handle. */ +/* Владелец rlottie-анимации TGS, все операции в GUI-потоке, без одновременного render. + * Конструктор копирует данные и принимает один полный gzip с JSON до MaxJsonBytes; + * усечённый/повреждённый/слишком большой вход даёт isValid()==false с диагностикой. + * Объект не копируется; деструктор освобождает handle. AnimTimer хранит только заимствованный + * указатель: перед уничтожением зарегистрированного icon вызвать unregisterIcon/shutdown. */ #pragma once #include @@ -12,28 +15,33 @@ class LottieIcon { public: explicit LottieIcon(const QByteArray &tgsData, const QString &key); ~LottieIcon(); + LottieIcon(const LottieIcon&) = delete; + LottieIcon& operator=(const LottieIcon&) = delete; + + static constexpr int MaxJsonBytes = 16 * 1024 * 1024; // бюджет распакованного JSON, не ограничение формата TGS bool isValid() const { return m_valid; } - QSize originalSize() const { return QSize((int)m_w, (int)m_h); } - int frameCount() const { return m_totalFrames; } - double frameRate() const { return m_frameRate; } + QSize originalSize() const { return m_valid ? QSize((int)m_w, (int)m_h) : QSize(0, 0); } // canvas, либо 0x0 для невалидного + int frameCount() const { return m_totalFrames; } // положительный при isValid() + double frameRate() const { return m_frameRate; } // метаданные; AnimTimer использует общий период 33 мс int currentFrame() const { return m_currentFrame; } int tgsSize() const { return m_tgsSize; } void setTgsSize(int sz) { m_tgsSize = sz; } - void logWarnings() const; + void logWarnings() const; // вывести сохранённые предупреждения парсера после настройки logger + // Независимый ARGB32 premultiplied, номер циклический, включая отрицательные; пустой QImage при ошибке/размере <=0. QImage renderFrame(int frameNum, QSize targetSize) const; - void advanceFrame() { m_currentFrame = (m_currentFrame + 1) % m_totalFrames; } + void advanceFrame(); // следующий кадр; невалидный объект остаётся на кадре 0 и выдаёт WARN private: bool m_valid = false; - struct Lottie_Animation_S *m_anim = nullptr; + struct Lottie_Animation_S *m_anim = nullptr; // собственный handle, включая невалидные метаданные после разбора int m_currentFrame = 0; int m_totalFrames = 0; int m_tgsSize = 0; double m_frameRate = 60.0; size_t m_w = 0, m_h = 0; - QByteArray m_jsonData; - QString m_key; - QStringList m_loadWarnings; + QByteArray m_jsonData; // JSON живёт до освобождения handle + QString m_key; // ключ rlottie-кеша: вызывающий задаёт уникальное имя ресурса/содержимого + QStringList m_loadWarnings; // warning callback доступен только во встроенной сборке с соответствующим API }; diff --git a/tools/chatgui/tests/test_emoji_playback.cpp b/tools/chatgui/tests/test_emoji_playback.cpp index 1aa6428f..c0d30deb 100644 --- a/tools/chatgui/tests/test_emoji_playback.cpp +++ b/tools/chatgui/tests/test_emoji_playback.cpp @@ -9,20 +9,21 @@ #include #include #include +#include #include #include #include static QStringList playbackLog; +static QStringList errorLog; -static void captureLog(int, const char *, const char *message) { +static void captureLog(int level, const char *, const char *message) { playbackLog.append(QString::fromUtf8(message)); + if (level == DEBUG_LEVEL_ERROR) errorLog.append(QString::fromUtf8(message)); } -// Два коротких цикла разной длины выявляют остановку посреди кадра и ожидание общего кратного. -static QByteArray animationData(int frames) { - QByteArray json = QString(R"({"v":"5.7.4","fr":30,"ip":0,"op":%1,"w":32,"h":32,"layers":[]})").arg(frames).toUtf8(); - QByteArray gzip(1024, '\0'); +static QByteArray gzipData(QByteArray json) { + QByteArray gzip(int(compressBound(json.size()) + 32), '\0'); z_stream stream = {}; if (deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 8, Z_DEFAULT_STRATEGY) != Z_OK) return {}; stream.next_in = reinterpret_cast(json.data()); @@ -35,6 +36,11 @@ static QByteArray animationData(int frames) { return result == Z_STREAM_END ? gzip : QByteArray(); } +// Два коротких цикла разной длины выявляют остановку посреди кадра и ожидание общего кратного. +static QByteArray animationData(int frames) { + return gzipData(QString(R"({"v":"5.7.4","fr":30,"ip":0,"op":%1,"w":32,"h":32,"layers":[]})").arg(frames).toUtf8()); +} + class EmojiPlaybackTest : public QObject { Q_OBJECT std::unique_ptr shortIcon, longIcon; @@ -45,6 +51,7 @@ private slots: debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG); debug_set_log_hook(captureLog); playbackLog.clear(); + errorLog.clear(); shortIcon = std::make_unique(animationData(7), "short"); longIcon = std::make_unique(animationData(11), "long"); QVERIFY(shortIcon->isValid()); @@ -60,6 +67,60 @@ private slots: longIcon.reset(); } + void rejectsInvalidAnimations_data() { + QTest::addColumn("data"); + const auto valid = animationData(7); + QTest::newRow("empty") << QByteArray(); + QTest::newRow("not-gzip") << QByteArray("not an animation"); + QTest::newRow("short-header") << valid.left(4); + QTest::newRow("missing-trailer") << valid.left(valid.size() - 4); + auto corrupt = valid; corrupt[corrupt.size() - 1] = char(corrupt.back() ^ 1); + QTest::newRow("invalid-size-checksum") << corrupt; + QTest::newRow("trailing-data") << valid + QByteArray("trailing"); + QTest::newRow("invalid-json") << gzipData("{broken"); + QTest::newRow("zero-frames") << animationData(0); + QTest::newRow("json-budget") << gzipData(QByteArray(LottieIcon::MaxJsonBytes + 1, ' ')); + } + + void rejectsInvalidAnimations() { + QFETCH(QByteArray, data); + LottieIcon invalid(data, QTest::currentDataTag()); + QVERIFY(!invalid.isValid()); + QVERIFY(!errorLog.filter("LottieIcon:").isEmpty()); + invalid.advanceFrame(); QCOMPARE(invalid.currentFrame(), 0); + QVERIFY(invalid.renderFrame(0, QSize(32, 32)).isNull()); + AnimTimer::instance()->registerIcon("invalid", &invalid); + QVERIFY(AnimTimer::instance()->icon("invalid") == nullptr); + AnimTimer::instance()->registerIcon("short", nullptr); + QCOMPARE(AnimTimer::instance()->icon("short"), shortIcon.get()); + QVERIFY(!playbackLog.filter("LottieIcon:").isEmpty()); + } + + void bundledAnimationsRemainValid() { + const QDir directory(QFINDTESTDATA("../resources/animations")); + const auto files = directory.entryList({"*.tgs"}, QDir::Files); + QVERIFY(!files.isEmpty()); + for (const QString& name : files) { + QFile file(directory.filePath(name)); QVERIFY(file.open(QIODevice::ReadOnly)); + LottieIcon icon(file.readAll(), "bundled-" + name); + QVERIFY2(icon.isValid(), qPrintable(name)); + QVERIFY(!icon.renderFrame(0, QSize(32, 32)).isNull()); + } + } + + void rendersOwnedImagesAndWrapsFrameNumbers() { + QFile file(QFINDTESTDATA("../resources/animations/robot.tgs")); QVERIFY(file.open(QIODevice::ReadOnly)); + LottieIcon icon(file.readAll(), "render-robot"); QVERIFY(icon.isValid()); + QImage first = icon.renderFrame(0, QSize(48, 48)); + QVERIFY(!first.isNull()); QCOMPARE(first.format(), QImage::Format_ARGB32_Premultiplied); + QCOMPARE(icon.renderFrame(-1, first.size()), icon.renderFrame(icon.frameCount() - 1, first.size())); + QCOMPARE(icon.renderFrame(icon.frameCount(), first.size()), first); + const QImage saved = first; + first.fill(Qt::red); + QCOMPARE(icon.renderFrame(0, saved.size()), saved); + QVERIFY(icon.renderFrame(0, QSize(0, 32)).isNull()); + } + void messagesFinishEachCycle() { auto *timer = AnimTimer::instance(); QSignalSpy ticks(timer, &AnimTimer::ticked);