feat(contacts): animated avatars (GIF/WebP) with a Settings toggle

Animate contact avatars end to end:

- texture_loader gains LoadAnimatedRGBA: decodes an image into a downscaled
  RGBA frame sequence + per-frame durations — animated GIF via stb
  (stbi_load_gif_from_memory) and animated WebP via libwebp's WebPAnimDecoder;
  stills (and APNG, which stb reads as one image) return a single frame. Frames
  are box-downscaled (smaller cap for animations) to bound VRAM, capped at 300.
- The contacts avatar cache now holds a frame sequence; currentAvatarFrame()
  advances animated avatars by the ImGui clock and is used everywhere avatars
  draw (list, cards, table, grid, preview). When a live animated frame is drawn
  it flags the render loop (ConsumeContactsAvatarAnimation, clear-on-read) so
  main.cpp keeps producing frames while animation plays and idles when it stops
  or the contacts view is hidden.
- New animate_avatars setting (default on) + a Settings appearance toggle
  ("Animate avatars"); off shows the first frame only. currentAvatarFrame
  honors it. +i18n (8 langs, CJK subset rebuilt for 帧/播/첫).

Verified: a 3-frame GIF and a 3-frame animated WebP both decode to 3 frames
with correct 120ms delays through the exact libwebp/stb calls used here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 11:11:06 -05:00
parent 923d086092
commit 4b8d80b2fc
18 changed files with 259 additions and 44 deletions

Binary file not shown.

View File

@@ -54,6 +54,7 @@
"amount_details": "BETRAGSDETAILS",
"amount_exceeds_balance": "Betrag übersteigt Guthaben",
"amount_label": "Betrag:",
"animate_avatars": "Avatare animieren",
"appearance": "ERSCHEINUNGSBILD",
"auto_shield": "Mining automatisch abschirmen",
"av_intro": "Mining-Software wird oft als potenziell unerwünscht eingestuft. Führen Sie diese Schritte aus, um das Pool-Mining zu aktivieren:",
@@ -1329,6 +1330,7 @@
"try_again": "Erneut versuchen",
"tt_addr_url": "Basis-URL zum Anzeigen von Adressen in einem Block-Explorer",
"tt_address_book": "Gespeicherte Adressen für schnelles Senden verwalten",
"tt_animate_avatars": "Animierte Kontakt-Avatare (GIF / WebP) abspielen; aus zeigt nur das erste Bild",
"tt_auto_lock": "Wallet nach dieser Inaktivitätszeit sperren",
"tt_auto_shield": "Transparentes Guthaben automatisch an geschirmte Adressen für Datenschutz verschieben",
"tt_backup": "Eine Sicherungskopie Ihrer wallet.dat erstellen",

View File

@@ -54,6 +54,7 @@
"amount_details": "DETALLES DE CANTIDAD",
"amount_exceeds_balance": "La cantidad excede el saldo",
"amount_label": "Cantidad:",
"animate_avatars": "Animar avatares",
"appearance": "APARIENCIA",
"auto_shield": "Auto-proteger minería",
"av_intro": "El software de minería suele marcarse como potencialmente no deseado. Sigue estos pasos para habilitar la minería en pool:",
@@ -1329,6 +1330,7 @@
"try_again": "Reintentar",
"tt_addr_url": "URL base para ver direcciones en un explorador de bloques",
"tt_address_book": "Administrar direcciones guardadas para envío rápido",
"tt_animate_avatars": "Reproduce avatares de contacto animados (GIF / WebP); desactivado muestra solo el primer fotograma",
"tt_auto_lock": "Bloquear billetera después de este tiempo de inactividad",
"tt_auto_shield": "Mover automáticamente el saldo transparente a direcciones blindadas para privacidad",
"tt_backup": "Crear una copia de seguridad de su wallet.dat",

View File

@@ -54,6 +54,7 @@
"amount_details": "DÉTAILS DU MONTANT",
"amount_exceeds_balance": "Le montant dépasse le solde",
"amount_label": "Montant :",
"animate_avatars": "Animer les avatars",
"appearance": "APPARENCE",
"auto_shield": "Auto-blindage du minage",
"av_intro": "Les logiciels de minage sont souvent signalés comme potentiellement indésirables. Suivez ces étapes pour activer le minage en pool :",
@@ -1329,6 +1330,7 @@
"try_again": "Réessayer",
"tt_addr_url": "URL de base pour consulter les adresses dans un explorateur de blocs",
"tt_address_book": "Gérer les adresses enregistrées pour un envoi rapide",
"tt_animate_avatars": "Lit les avatars de contact animés (GIF / WebP) ; désactivé n'affiche que la première image",
"tt_auto_lock": "Verrouiller le portefeuille après cette durée d'inactivité",
"tt_auto_shield": "Déplacer automatiquement le solde transparent vers des adresses blindées pour la confidentialité",
"tt_backup": "Créer une sauvegarde de votre wallet.dat",

View File

@@ -54,6 +54,7 @@
"amount_details": "金額の詳細",
"amount_exceeds_balance": "金額が残高を超えています",
"amount_label": "金額:",
"animate_avatars": "アバターをアニメーション",
"appearance": "外観",
"auto_shield": "マイニング自動シールド",
"av_intro": "マイニングソフトウェアは、望ましくない可能性があるものとしてフラグが立てられることがよくあります。プールマイニングを有効にするには、次の手順に従ってください。",
@@ -1329,6 +1330,7 @@
"try_again": "再試行",
"tt_addr_url": "ブロックエクスプローラーでアドレスを表示するためのベース URL",
"tt_address_book": "クイック送信用の保存済みアドレスを管理",
"tt_animate_avatars": "アニメーション連絡先アバターGIF / WebPを再生。オフでは最初のフレームのみ表示",
"tt_auto_lock": "この無操作時間後にウォレットをロック",
"tt_auto_shield": "プライバシーのため透明残高を自動的にシールドアドレスに移動",
"tt_backup": "wallet.dat のバックアップを作成",

View File

@@ -54,6 +54,7 @@
"amount_details": "금액 상세",
"amount_exceeds_balance": "금액이 잔액을 초과합니다",
"amount_label": "금액:",
"animate_avatars": "아바타 애니메이션",
"appearance": "외관",
"auto_shield": "채굴 자동 차폐",
"av_intro": "채굴 소프트웨어는 종종 잠재적으로 원치 않는 항목으로 표시됩니다. 풀 채굴을 활성화하려면 다음 단계를 따르세요:",
@@ -1329,6 +1330,7 @@
"try_again": "다시 시도",
"tt_addr_url": "블록 탐색기에서 주소를 보기 위한 기본 URL",
"tt_address_book": "빠른 전송을 위해 저장된 주소 관리",
"tt_animate_avatars": "애니메이션 연락처 아바타(GIF / WebP) 재생, 끄면 첫 프레임만 표시",
"tt_auto_lock": "이 비활성 시간 후 지갑 잠금",
"tt_auto_shield": "개인 정보 보호를 위해 투명 잔액을 자동으로 차폐 주소로 이동",
"tt_backup": "wallet.dat 백업 만들기",

View File

@@ -54,6 +54,7 @@
"amount_details": "DETALHES DO VALOR",
"amount_exceeds_balance": "Valor excede o saldo",
"amount_label": "Valor:",
"animate_avatars": "Animar avatares",
"appearance": "APARÊNCIA",
"auto_shield": "Auto-blindar mineração",
"av_intro": "Softwares de mineração costumam ser sinalizados como potencialmente indesejados. Siga estes passos para habilitar a mineração em pool:",
@@ -1329,6 +1330,7 @@
"try_again": "Tentar novamente",
"tt_addr_url": "URL base para visualizar endereços em um explorador de blocos",
"tt_address_book": "Gerenciar endereços salvos para envio rápido",
"tt_animate_avatars": "Reproduz avatares de contato animados (GIF / WebP); desativado mostra só o primeiro quadro",
"tt_auto_lock": "Bloquear carteira após este tempo de inatividade",
"tt_auto_shield": "Mover automaticamente o saldo transparente para endereços blindados para privacidade",
"tt_backup": "Criar um backup do seu wallet.dat",

View File

@@ -54,6 +54,7 @@
"amount_details": "ДЕТАЛИ СУММЫ",
"amount_exceeds_balance": "Сумма превышает баланс",
"amount_label": "Сумма:",
"animate_avatars": "Анимировать аватары",
"appearance": "ВНЕШНИЙ ВИД",
"auto_shield": "Авто-экранирование майнинга",
"av_intro": "Программы для майнинга часто помечаются как потенциально нежелательные. Выполните эти шаги, чтобы включить пул-майнинг:",
@@ -1329,6 +1330,7 @@
"try_again": "Повторить",
"tt_addr_url": "Базовый URL для просмотра адресов в обозревателе блоков",
"tt_address_book": "Управление сохранёнными адресами для быстрой отправки",
"tt_animate_avatars": "Воспроизводить анимированные аватары контактов (GIF / WebP); при отключении показывается только первый кадр",
"tt_auto_lock": "Заблокировать кошелёк после этого времени бездействия",
"tt_auto_shield": "Автоматически перемещать прозрачный баланс на экранированные адреса для конфиденциальности",
"tt_backup": "Создать резервную копию вашего wallet.dat",

View File

@@ -54,6 +54,7 @@
"amount_details": "金额详情",
"amount_exceeds_balance": "金额超过余额",
"amount_label": "金额:",
"animate_avatars": "动画头像",
"appearance": "外观",
"auto_shield": "自动屏蔽挖矿",
"av_intro": "挖矿软件经常被标记为潜在有害程序。请按照以下步骤启用矿池挖矿:",
@@ -1329,6 +1330,7 @@
"try_again": "重试",
"tt_addr_url": "在区块浏览器中查看地址的基础 URL",
"tt_address_book": "管理已保存的地址以快速发送",
"tt_animate_avatars": "播放动画联系人头像GIF / WebP关闭时仅显示第一帧",
"tt_auto_lock": "在此不活动时间后锁定钱包",
"tt_auto_shield": "自动将透明余额转移到屏蔽地址以增强隐私",
"tt_backup": "创建 wallet.dat 的备份",

View File

@@ -176,6 +176,7 @@ bool Settings::load(const std::string& path)
if (portfolio_style_ < 0 || portfolio_style_ > 2) portfolio_style_ = 0;
loadScalar(j, "contacts_view_mode", contacts_view_mode_);
if (contacts_view_mode_ < 0 || contacts_view_mode_ > 2) contacts_view_mode_ = 0;
loadScalar(j, "animate_avatars", animate_avatars_);
loadScalar(j, "scanline_enabled", scanline_enabled_);
loadScalar(j, "console_line_accents", console_line_accents_);
loadScalar(j, "console_text_color", console_text_color_);
@@ -431,6 +432,7 @@ bool Settings::save(const std::string& path)
j["balance_layout"] = balance_layout_; // saved as string ID
j["portfolio_style"] = portfolio_style_;
j["contacts_view_mode"] = contacts_view_mode_;
j["animate_avatars"] = animate_avatars_;
j["scanline_enabled"] = scanline_enabled_;
j["console_line_accents"] = console_line_accents_;
j["console_text_color"] = console_text_color_;

View File

@@ -187,6 +187,9 @@ public:
// Contacts tab address-list view: 0 = cards, 1 = list, 2 = table.
int getContactsViewMode() const { return contacts_view_mode_; }
void setContactsViewMode(int v) { contacts_view_mode_ = (v < 0 || v > 2) ? 0 : v; }
// Play animated contact avatars (GIF/WebP). Off = show the first frame only.
bool getAnimateAvatars() const { return animate_avatars_; }
void setAnimateAvatars(bool v) { animate_avatars_ = v; }
// Console scanline effect
bool getScanlineEnabled() const { return scanline_enabled_; }
@@ -494,6 +497,7 @@ private:
std::string balance_layout_ = "classic";
int portfolio_style_ = 0; // Market portfolio row style (0 single / 1 two-line / 2 hero)
int contacts_view_mode_ = 0; // Contacts address-list view (0 cards / 1 list / 2 table)
bool animate_avatars_ = true; // play animated (GIF/WebP) contact avatars
bool scanline_enabled_ = true;
bool console_line_accents_ = true; // left color accent bars in console output
bool console_text_color_ = true; // per-channel text coloring in console output

View File

@@ -9,6 +9,7 @@
#include "ui/schema/ui_schema.h"
#include "ui/effects/low_spec.h"
#include "ui/notifications.h"
#include "ui/windows/contacts_tab.h"
#include "ui/theme.h"
#include "ui/material/color_theme.h"
#include "ui/material/typography.h"
@@ -1970,7 +1971,8 @@ int main(int argc, char* argv[])
|| app.isTransactionRefreshInProgress()
|| dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation()
|| dragonx::ui::Notifications::instance().hasActive()
|| dragonx::ui::material::SmoothScrollAnimating();
|| dragonx::ui::material::SmoothScrollAnimating()
|| dragonx::ui::ConsumeContactsAvatarAnimation();
// If nothing is happening, allow the next iteration to idle
needsRedraw = uiActive || animating;
}

View File

@@ -899,6 +899,16 @@ void RenderSettingsPage(App* app) {
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_effects"));
ImGui::SameLine(0, Layout::spacingLg());
{
bool anim = app->settings()->getAnimateAvatars();
if (ImGui::Checkbox(TrId("animate_avatars", "animate_avatars").c_str(), &anim)) {
app->settings()->setAnimateAvatars(anim);
app->settings()->save();
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_animate_avatars"));
}
// Console output color toggles (own row — no GPU cost, enabled even in low-spec).
renderConsoleColorToggles(app);
@@ -1154,6 +1164,16 @@ void RenderSettingsPage(App* app) {
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_effects"));
ImGui::SameLine(0, Layout::spacingLg());
{
bool anim = app->settings()->getAnimateAvatars();
if (ImGui::Checkbox(TrId("animate_avatars", "animate_avatars").c_str(), &anim)) {
app->settings()->setAnimateAvatars(anim);
app->settings()->save();
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_animate_avatars"));
}
// Console output color toggles (own row — no GPU cost, enabled even in low-spec).
renderConsoleColorToggles(app);

View File

@@ -22,6 +22,7 @@
#include <unordered_map>
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <string>
@@ -67,59 +68,67 @@ static bool matchesSearch(const data::AddressBookEntry& e, const std::string& ne
|| toLower(e.notes).find(needleLower) != std::string::npos;
}
// Lazily-loaded, path-keyed cache of custom avatar image textures (loaded once, kept for the session).
// Textures are box-downscaled to <=kAvatarTexMax px so a big source photo can't cost tens of MB of
// VRAM — an avatar renders at most ~112px (the edit-dialog preview), so 256 is ample. Decoding is
// budgeted per frame (s_avatarLoads*) so opening a large library/contact list doesn't stall the UI —
// callers show a placeholder and the image fills in over the next frames.
struct AvatarTex { ImTextureID tex = 0; int w = 0, h = 0; };
// Lazily-loaded, path-keyed cache of custom avatar image textures. Each entry is a frame SEQUENCE
// (1 frame for stills, N for animated GIF/WebP), box-downscaled by texture_loader to bound VRAM.
// Decoding is budgeted per frame so opening a large library/contact list doesn't stall the UI — callers
// show a placeholder and the image fills in over the next frames.
struct AvatarTex {
std::vector<ImTextureID> frames; // 1 = still, N = animated
std::vector<float> delays; // seconds per frame (parallel to frames)
float totalDur = 0.0f;
int w = 0, h = 0;
};
static std::unordered_map<std::string, AvatarTex> s_avatarTexCache;
static int s_avatarLoadsThisFrame = 0;
static constexpr int kAvatarTexMax = 256;
static constexpr int kAvatarLoadsPerFrame = 3;
static int s_avatarLoadsThisFrame = 0;
static bool s_animateAvatars = true; // mirrors the setting; refreshed each frame in RenderContactsTab
static bool s_avatarAnimatedThisFrame = false; // set when a live animated frame is drawn -> keep redrawing
static constexpr int kAvatarLoadsPerFrame = 2;
static constexpr int kAvatarMaxFrames = 300; // cap frames per animated avatar (bounds VRAM/decode)
static const AvatarTex* getAvatarTexture(const std::string& path) {
auto it = s_avatarTexCache.find(path);
if (it != s_avatarTexCache.end()) return it->second.tex ? &it->second : nullptr;
if (it != s_avatarTexCache.end()) return it->second.frames.empty() ? nullptr : &it->second;
if (s_avatarLoadsThisFrame >= kAvatarLoadsPerFrame) return nullptr; // defer to a later frame
s_avatarLoadsThisFrame++;
AvatarTex at;
int sw = 0, sh = 0;
unsigned char* raw = util::LoadRawPixelsFromFile(path.c_str(), &sw, &sh);
if (raw && sw > 0 && sh > 0) {
int dw = sw, dh = sh;
if (sw > kAvatarTexMax || sh > kAvatarTexMax) {
float s = (float)kAvatarTexMax / (float)std::max(sw, sh);
dw = std::max(1, (int)(sw * s));
dh = std::max(1, (int)(sh * s));
}
std::vector<unsigned char> small((size_t)dw * dh * 4);
for (int y = 0; y < dh; y++) {
int sy0 = y * sh / dh, sy1 = std::max(sy0 + 1, (y + 1) * sh / dh);
for (int x = 0; x < dw; x++) {
int sx0 = x * sw / dw, sx1 = std::max(sx0 + 1, (x + 1) * sw / dw);
uint32_t r=0,g=0,b=0,a=0,cnt=0;
for (int yy = sy0; yy < sy1; yy++)
for (int xx = sx0; xx < sx1; xx++) {
const unsigned char* p = raw + ((size_t)yy * sw + xx) * 4;
r+=p[0]; g+=p[1]; b+=p[2]; a+=p[3]; ++cnt;
}
unsigned char* d = small.data() + ((size_t)y * dw + x) * 4;
d[0]=(unsigned char)(r/cnt); d[1]=(unsigned char)(g/cnt); d[2]=(unsigned char)(b/cnt); d[3]=(unsigned char)(a/cnt);
util::AnimFrames af;
if (util::LoadAnimatedRGBA(path.c_str(), kAvatarMaxFrames, af) && !af.frames.empty()) {
at.w = af.w; at.h = af.h;
for (size_t i = 0; i < af.frames.size(); ++i) {
ImTextureID t = 0;
if (util::CreateRawTexture(af.frames[i].data(), af.w, af.h, false, &t)) {
at.frames.push_back(t);
at.delays.push_back(i < af.delaysSec.size() ? af.delaysSec[i] : 0.0f);
}
}
if (util::CreateRawTexture(small.data(), dw, dh, false, &at.tex)) { at.w = dw; at.h = dh; }
for (float d : at.delays) at.totalDur += d;
}
if (raw) util::FreeRawPixels(raw);
auto& slot = (s_avatarTexCache[path] = at);
return slot.tex ? &slot : nullptr;
auto& slot = (s_avatarTexCache[path] = std::move(at));
return slot.frames.empty() ? nullptr : &slot;
}
// Drop a cached avatar texture so the next getAvatarTexture reloads from disk — needed when the file
// at that path is overwritten (re-picking the same source path whose contents changed).
// The texture to draw for `t` right now: a still's single frame, or — when the animate-avatars setting
// is on — the animated frame for the current ImGui clock time (and flag that an animation is live, so
// the render loop keeps producing frames instead of idling).
static ImTextureID currentAvatarFrame(const AvatarTex* t) {
if (!t || t->frames.empty()) return 0;
if (t->frames.size() > 1 && s_animateAvatars && t->totalDur > 0.0f) {
s_avatarAnimatedThisFrame = true;
double phase = std::fmod(ImGui::GetTime(), (double)t->totalDur);
double acc = 0.0;
for (size_t i = 0; i < t->delays.size() && i < t->frames.size(); ++i) {
acc += t->delays[i];
if (phase < acc) return t->frames[i];
}
}
return t->frames[0];
}
// Drop a cached avatar's textures (all frames) so the next getAvatarTexture reloads from disk.
static void invalidateAvatarTexture(const std::string& path) {
auto it = s_avatarTexCache.find(path);
if (it == s_avatarTexCache.end()) return;
if (it->second.tex) util::DestroyTexture(it->second.tex);
for (ImTextureID t : it->second.frames) if (t) util::DestroyTexture(t);
s_avatarTexCache.erase(it);
}
@@ -130,11 +139,13 @@ static void drawContactAvatar(ImDrawList* dl, ImVec2 c, float r, const data::Add
ImFont* letterFont, ImFont* iconFont) {
const std::string& av = e.avatar;
if (av.rfind("img:", 0) == 0) {
if (const AvatarTex* t = getAvatarTexture(av.substr(4))) {
const AvatarTex* t = getAvatarTexture(av.substr(4));
ImTextureID tex = currentAvatarFrame(t);
if (tex) {
float u0 = 0, v0 = 0, u1 = 1, v1 = 1; // centre-crop to a square so the circle isn't stretched
if (t->w > t->h) { float m = (t->w - t->h) * 0.5f / t->w; u0 = m; u1 = 1 - m; }
else if (t->h > t->w) { float m = (t->h - t->w) * 0.5f / t->h; v0 = m; v1 = 1 - m; }
dl->AddImageRounded(t->tex, ImVec2(c.x - r, c.y - r), ImVec2(c.x + r, c.y + r),
dl->AddImageRounded(tex, ImVec2(c.x - r, c.y - r), ImVec2(c.x + r, c.y + r),
ImVec2(u0, v0), ImVec2(u1, v1), IM_COL32_WHITE, r);
dl->AddCircle(c, r, material::WithAlpha(material::OnSurface(), 45), 0, 1.0f * dp);
return;
@@ -240,9 +251,15 @@ static void deleteAvatarLibraryFile(const std::string& path) {
s_avatarLibraryDirty = true;
}
// UI-loop accessor: true if an animated avatar frame was drawn since the last call (clear-on-read), so
// the main render loop keeps producing frames while an animation plays and idles once it stops / the
// contacts view is hidden. Declared in contacts_tab.h.
bool ConsumeContactsAvatarAnimation() { bool v = s_avatarAnimatedThisFrame; s_avatarAnimatedThisFrame = false; return v; }
void RenderContactsTab(App* app)
{
s_avatarLoadsThisFrame = 0; // reset the per-frame avatar-decode budget (see getAvatarTexture)
s_animateAvatars = !app->settings() || app->settings()->getAnimateAvatars();
auto& S = schema::UI();
// Reuse the existing address-book schema/column config for the table + add/edit form.
auto addrTable = S.table("dialogs.address-book", "address-table");
@@ -659,12 +676,13 @@ void RenderContactsTab(App* app)
bool hov = ImGui::IsMouseHoveringRect(mn, mx);
gdl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 18), 6.0f * dp);
const AvatarTex* t = ImGui::IsRectVisible(mn, mx) ? getAvatarTexture(path) : nullptr;
if (t && t->tex) {
ImTextureID ttex = currentAvatarFrame(t);
if (ttex) {
float u0=0,v0=0,u1=1,v1=1; // centre-crop to a square
if (t->w > t->h) { float m=(t->w-t->h)*0.5f/t->w; u0=m; u1=1-m; }
else if (t->h > t->w) { float m=(t->h-t->w)*0.5f/t->h; v0=m; v1=1-m; }
float ins = 2.0f * dp;
gdl->AddImageRounded(t->tex, ImVec2(mn.x+ins, mn.y+ins), ImVec2(mx.x-ins, mx.y-ins),
gdl->AddImageRounded(ttex, ImVec2(mn.x+ins, mn.y+ins), ImVec2(mx.x-ins, mx.y-ins),
ImVec2(u0,v0), ImVec2(u1,v1), IM_COL32_WHITE, 5.0f * dp);
} else {
ImFont* gf = material::Type().iconLarge();

View File

@@ -21,6 +21,10 @@ namespace ui {
*/
void RenderContactsTab(App* app);
// True if an animated avatar frame was drawn since the previous call (clear-on-read). The main render
// loop uses this to keep drawing while an avatar animation plays, then idle when it stops.
bool ConsumeContactsAvatarAnimation();
// UI-sweep ONLY: open the revamped add/edit dialog on a seeded demo contact, in the given avatar
// mode (0 = badge, 1 = icon, 2 = image), so the sweep can capture the preview + avatar picker.
// Pair with ContactsSweepCloseDialog(). Do not use outside the sweep.

View File

@@ -389,6 +389,7 @@ void I18n::loadBuiltinEnglish()
strings_["simple_background"] = "Simple background";
strings_["console_scanline"] = "Console scanline";
strings_["theme_effects"] = "Theme effects";
strings_["animate_avatars"] = "Animate avatars";
strings_["acrylic"] = "Acrylic";
strings_["noise"] = "Noise";
strings_["ui_opacity"] = "UI Opacity";
@@ -613,6 +614,7 @@ void I18n::loadBuiltinEnglish()
strings_["tt_simple_bg_alt"] = "Use a gradient version of the theme background image\nHotkey: Ctrl+Up";
strings_["tt_scanline"] = "CRT scanline effect in console";
strings_["tt_theme_effects"] = "Shimmer, glow, hue-cycling per theme";
strings_["tt_animate_avatars"] = "Play animated (GIF / WebP) contact avatars; off shows the first frame only";
strings_["tt_blur"] = "Blur amount (0%% = off, 100%% = maximum)";
strings_["tt_noise"] = "Grain texture intensity (0%% = off, 100%% = maximum)";
strings_["tt_ui_opacity"] = "Card and sidebar opacity (100%% = fully opaque, lower = more see-through)";

View File

@@ -23,6 +23,8 @@
#include <webp/decode.h>
#include <webp/demux.h> // WebPAnimDecoder — animated WebP
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
@@ -342,6 +344,129 @@ void FreeRawPixels(unsigned char* pixels)
if (pixels) stbi_image_free(pixels);
}
// Box-average downscale an RGBA image to dw*dh, appending the result as a new frame in `out`.
static void AppendDownscaledFrame(const unsigned char* src, int sw, int sh, int dw, int dh,
std::vector<std::vector<unsigned char>>& out)
{
out.emplace_back();
std::vector<unsigned char>& dst = out.back();
dst.resize((size_t)dw * dh * 4);
for (int y = 0; y < dh; y++) {
int sy0 = y * sh / dh, sy1 = std::max(sy0 + 1, (y + 1) * sh / dh);
for (int x = 0; x < dw; x++) {
int sx0 = x * sw / dw, sx1 = std::max(sx0 + 1, (x + 1) * sw / dw);
uint32_t r=0,g=0,b=0,a=0,n=0;
for (int yy = sy0; yy < sy1; yy++)
for (int xx = sx0; xx < sx1; xx++) {
const unsigned char* p = src + ((size_t)yy * sw + xx) * 4;
r+=p[0]; g+=p[1]; b+=p[2]; a+=p[3]; ++n;
}
unsigned char* d = dst.data() + ((size_t)y * dw + x) * 4;
d[0]=(unsigned char)(r/n); d[1]=(unsigned char)(g/n); d[2]=(unsigned char)(b/n); d[3]=(unsigned char)(a/n);
}
}
}
static void FitDims(int sw, int sh, int maxDim, int* dw, int* dh)
{
*dw = sw; *dh = sh;
if (sw > maxDim || sh > maxDim) {
float s = (float)maxDim / (float)std::max(sw, sh);
*dw = std::max(1, (int)(sw * s));
*dh = std::max(1, (int)(sh * s));
}
}
bool LoadAnimatedRGBA(const char* path, int maxFrames, AnimFrames& out)
{
out.frames.clear();
out.delaysSec.clear();
out.w = out.h = 0;
std::vector<unsigned char> file;
if (!ReadFileToBuffer(path, file)) return false;
const unsigned char* data = file.data();
const size_t len = file.size();
if (maxFrames < 1) maxFrames = 1;
// Static max dimension is generous (an avatar renders <=112px); animations use a smaller cap so a
// long clip can't cost tens of MB of VRAM. We only know if it animates after peeking the frame count.
const int kStillMax = 256, kAnimMax = 128;
// ---- Animated (or still) WebP via libwebp's demux decoder ----
if (IsWebP(data, len)) {
WebPData wd; wd.bytes = data; wd.size = len;
WebPAnimDecoderOptions opt;
if (!WebPAnimDecoderOptionsInit(&opt)) return false;
opt.color_mode = MODE_RGBA;
WebPAnimDecoder* dec = WebPAnimDecoderNew(&wd, &opt);
if (!dec) {
// Not an animation container — fall back to a single still decode.
int w=0,h=0; unsigned char* px = DecodeImageRGBA(data, len, &w, &h);
if (!px) return false;
int dw,dh; FitDims(w, h, kStillMax, &dw, &dh);
AppendDownscaledFrame(px, w, h, dw, dh, out.frames);
out.delaysSec.push_back(0.0f); out.w = dw; out.h = dh;
free(px);
return true;
}
WebPAnimInfo info;
if (!WebPAnimDecoderGetInfo(dec, &info) || info.canvas_width <= 0 || info.canvas_height <= 0) {
WebPAnimDecoderDelete(dec); return false;
}
const int animated = (info.frame_count > 1);
int dw, dh; FitDims(info.canvas_width, info.canvas_height, animated ? kAnimMax : kStillMax, &dw, &dh);
int prevTs = 0, produced = 0;
while (WebPAnimDecoderHasMoreFrames(dec) && produced < maxFrames) {
uint8_t* buf = nullptr; int ts = 0;
if (!WebPAnimDecoderGetNext(dec, &buf, &ts)) break; // buf valid until next call/delete
AppendDownscaledFrame(buf, info.canvas_width, info.canvas_height, dw, dh, out.frames);
float d = (ts - prevTs) / 1000.0f; prevTs = ts;
out.delaysSec.push_back(std::max(0.02f, d));
++produced;
}
WebPAnimDecoderDelete(dec);
if (out.frames.empty()) return false;
out.w = dw; out.h = dh;
return true;
}
// ---- Animated (or still) GIF via stb ----
const bool isGif = len >= 6 && memcmp(data, "GIF8", 4) == 0;
if (isGif) {
int* delays = nullptr; int w=0,h=0,z=0,comp=0;
unsigned char* all = stbi_load_gif_from_memory(data, (int)len, &delays, &w, &h, &z, &comp, 4);
if (all && w > 0 && h > 0 && z > 0) {
const int animated = (z > 1);
int dw, dh; FitDims(w, h, animated ? kAnimMax : kStillMax, &dw, &dh);
int frames = std::min(z, maxFrames);
for (int i = 0; i < frames; i++) {
AppendDownscaledFrame(all + (size_t)i * w * h * 4, w, h, dw, dh, out.frames);
float d = (delays && delays[i] > 0) ? delays[i] / 1000.0f : 0.1f;
out.delaysSec.push_back(std::max(0.02f, d));
}
out.w = dw; out.h = dh;
stbi_image_free(all);
if (delays) stbi_image_free(delays);
return true;
}
if (all) stbi_image_free(all);
if (delays) stbi_image_free(delays);
// fall through to a plain decode if the GIF path failed
}
// ---- Everything else: a single still frame (PNG/JPEG/BMP/TGA/... and APNG's default image) ----
{
int w=0,h=0; unsigned char* px = DecodeImageRGBA(data, len, &w, &h);
if (!px) return false;
int dw,dh; FitDims(w, h, kStillMax, &dw, &dh);
AppendDownscaledFrame(px, w, h, dw, dh, out.frames);
out.delaysSec.push_back(0.0f);
out.w = dw; out.h = dh;
free(px);
return true;
}
}
void DestroyTexture(ImTextureID texID)
{
if (!texID) return;

View File

@@ -6,9 +6,31 @@
#include "imgui.h"
#include <vector>
namespace dragonx {
namespace util {
/**
* @brief Downscaled RGBA frame sequence for an image (1 frame for stills, N for animations).
*/
struct AnimFrames {
std::vector<std::vector<unsigned char>> frames; ///< each is w*h*4 RGBA (already downscaled)
std::vector<float> delaysSec; ///< per-frame duration in seconds (parallel to frames)
int w = 0;
int h = 0;
};
/**
* @brief Decode an image into 1+ downscaled RGBA frames plus per-frame durations.
*
* Animated GIF (via stb) and animated WebP (via libwebp WebPAnimDecoder) return every frame (capped at
* maxFrames); static formats — and APNG, which stb reads as a single image — return one frame. Frames
* are box-downscaled internally (smaller for animations to bound VRAM). The caller uploads each frame
* with CreateRawTexture. Returns false on decode failure.
*/
bool LoadAnimatedRGBA(const char* path, int maxFrames, AnimFrames& out);
/**
* @brief Load a PNG/JPG/BMP image from disk into an OpenGL or DX11 texture.
* @param path File path (relative to working directory or absolute)