Bound TGS decoding and keep invalid icons out of animation
This commit is contained in:
1 parent
41ce04e2f0
commit
b0acb285c1
5 files changed
+166
-66
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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/завершения
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user