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

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.