Bound TGS decoding and keep invalid icons out of animation

This commit is contained in:
evgeny committed 2026-10-27 03:33:50 +01:00
1 parent 41ce04e2f0
commit b0acb285c1
5 files changed
+166 -66

No files matched your search

+1
View File
@@ -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);
}
+16 -12
View File
@@ -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 <QObject>
@@ -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<QString, LottieIcon *> m_icons;
QElapsedTimer m_playback;
QTimer m_timer; // период 33 мс; закончивший цикл icon после дедлайна больше не продвигается
QHash<QString, LottieIcon *> m_icons; // не владеет значками, доступ только в GUI
QElapsedTimer m_playback; // время с последнего play, invalid после shutdown/завершения
};
+64 -38
View File
@@ -2,7 +2,8 @@
#include "debug_ui.h"
#include <rlottie_capi.h>
#include <zlib.h>
#include <cstring>
#include <array>
#include <limits>
#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<int>::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<Bytef*>(const_cast<char*>(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<char, 16384> chunk;
bool complete = false;
for (;;) {
const auto before = strm.total_in;
strm.avail_out = chunk.size();
strm.next_out = reinterpret_cast<Bytef*>(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<int>::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<int>::max()) || !m_w || !m_h ||
m_w > size_t(std::numeric_limits<int>::max()) || m_h > size_t(std::numeric_limits<int>::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<uint32_t> 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<uint32_t*>(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;
}
+19 -11
View File
@@ -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 <QImage>
@@ -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
};
+66 -5
View File
@@ -9,20 +9,21 @@
#include <QEnterEvent>
#include <QPushButton>
#include <QTabWidget>
#include <QFile>
#include <QStandardItemModel>
#include <zlib.h>
#include <memory>
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<Bytef *>(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<LottieIcon> 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<LottieIcon>(animationData(7), "short");
longIcon = std::make_unique<LottieIcon>(animationData(11), "long");
QVERIFY(shortIcon->isValid());
@@ -60,6 +67,60 @@ private slots:
longIcon.reset();
}
void rejectsInvalidAnimations_data() {
QTest::addColumn<QByteArray>("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);