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:
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user