From 4b8d80b2fc3919fcee66b557f066c4f1c8c9161e Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 15 Jul 2026 11:11:06 -0500 Subject: [PATCH] feat(contacts): animated avatars (GIF/WebP) with a Settings toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- res/fonts/NotoSansCJK-Subset.ttf | Bin 634428 -> 635568 bytes res/lang/de.json | 2 + res/lang/es.json | 2 + res/lang/fr.json | 2 + res/lang/ja.json | 2 + res/lang/ko.json | 2 + res/lang/pt.json | 2 + res/lang/ru.json | 2 + res/lang/zh.json | 2 + src/config/settings.cpp | 2 + src/config/settings.h | 4 + src/main.cpp | 4 +- src/ui/pages/settings_page.cpp | 20 +++++ src/ui/windows/contacts_tab.cpp | 104 ++++++++++++++----------- src/ui/windows/contacts_tab.h | 4 + src/util/i18n.cpp | 2 + src/util/texture_loader.cpp | 125 +++++++++++++++++++++++++++++++ src/util/texture_loader.h | 22 ++++++ 18 files changed, 259 insertions(+), 44 deletions(-) diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index a238fc627a968efab3bc76301ccb8cb812e0cabf..91228914a50a9845f85fefc9b63491ce14b5a2d3 100644 GIT binary patch delta 20848 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 delta 19710 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 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_; diff --git a/src/config/settings.h b/src/config/settings.h index 89b0cdf..67c32b8 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -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 diff --git a/src/main.cpp b/src/main.cpp index 00fcbab..e9a3070 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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; } diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index f08c620..e5cf22a 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -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); diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index b8ac673..273b011 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -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 frames; // 1 = still, N = animated + std::vector delays; // seconds per frame (parallel to frames) + float totalDur = 0.0f; + int w = 0, h = 0; +}; static std::unordered_map 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 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(); diff --git a/src/ui/windows/contacts_tab.h b/src/ui/windows/contacts_tab.h index 71d298a..691a294 100644 --- a/src/ui/windows/contacts_tab.h +++ b/src/ui/windows/contacts_tab.h @@ -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. diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index be24c3d..2e67a47 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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)"; diff --git a/src/util/texture_loader.cpp b/src/util/texture_loader.cpp index 4fdaa4c..e31217d 100644 --- a/src/util/texture_loader.cpp +++ b/src/util/texture_loader.cpp @@ -23,6 +23,8 @@ #include #include // WebPAnimDecoder — animated WebP +#include +#include #include #include #include @@ -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>& out) +{ + out.emplace_back(); + std::vector& 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 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; diff --git a/src/util/texture_loader.h b/src/util/texture_loader.h index 6580504..826277a 100644 --- a/src/util/texture_loader.h +++ b/src/util/texture_loader.h @@ -6,9 +6,31 @@ #include "imgui.h" +#include + namespace dragonx { namespace util { +/** + * @brief Downscaled RGBA frame sequence for an image (1 frame for stills, N for animations). + */ +struct AnimFrames { + std::vector> frames; ///< each is w*h*4 RGBA (already downscaled) + std::vector 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)