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