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

@@ -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)