From e7f2d2e3c77c26c8f75638a6d26715408a11018b Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 15 Jul 2026 11:44:57 -0500 Subject: [PATCH] feat(image-picker): animated-image badge on GIF/WebP thumbnails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Animated thumbnails now show a small play-arrow badge (bottom-right) so users can spot which images move before hovering; it's hidden while the image plays on hover. Detection is cheap — a new util::IsAnimatedImageFile probes without a full decode: animated WebP via WebPGetFeatures.has_animation, and a multi-frame GIF via a lightweight image-descriptor block walk (stops at the 2nd frame). The picker only probes .gif/.webp thumbnails and caches the result on the Thumb. Verified: animated GIF + animated WebP report animated; still GIF/WebP/PNG do not. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/windows/image_picker.h | 17 +++++++++++++ src/util/texture_loader.cpp | 46 +++++++++++++++++++++++++++++++++++ src/util/texture_loader.h | 6 +++++ 3 files changed, 69 insertions(+) diff --git a/src/ui/windows/image_picker.h b/src/ui/windows/image_picker.h index 817f2ab..f635df5 100644 --- a/src/ui/windows/image_picker.h +++ b/src/ui/windows/image_picker.h @@ -258,6 +258,19 @@ public: dl->AddText(icoFont, cell*0.34f, ImVec2(cc.x - cell*0.17f, cc.y - cell*0.17f), OnSurfaceDisabled(), ICON_MD_IMAGE); } + // "Animated" badge (bottom-right) on GIF/WebP that move — hidden while it plays on hover. + if (t && t->animated && !hov) { + float bh = std::max(13.0f * dp, cell * 0.24f); + float bw = bh * 1.15f; + ImVec2 bmax(mx.x - 4.0f * dp, mx.y - 4.0f * dp); + ImVec2 bmin(bmax.x - bw, bmax.y - bh); + dl->AddRectFilled(bmin, bmax, IM_COL32(0, 0, 0, 180), 4.0f * dp); + float gsz = bh * 0.92f; + ImVec2 gs = icoFont->CalcTextSizeA(gsz, FLT_MAX, 0, ICON_MD_PLAY_ARROW); + dl->AddText(icoFont, gsz, + ImVec2((bmin.x + bmax.x) * 0.5f - gs.x * 0.5f, (bmin.y + bmax.y) * 0.5f - gs.y * 0.5f), + IM_COL32(255, 255, 255, 235), ICON_MD_PLAY_ARROW); + } if (sel) dl->AddRect(mn, mx, WithAlpha(Primary(), 220), 6.0f * dp, 0, 2.0f * dp); else if (hov) { dl->AddRect(mn, mx, WithAlpha(OnSurface(), 90), 6.0f * dp, 0, 1.5f * dp); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); } if (hov) material::Tooltip("%s", s_images[i].c_str()); @@ -316,6 +329,7 @@ public: private: struct Thumb { ImTextureID tex = 0; int w = 0, h = 0; bool tried = false; // static frame-0 thumbnail + bool animated = false; // multi-frame GIF/WebP (badge) std::vector frames; // full sequence (loaded on hover) std::vector delays; int aw = 0, ah = 0; float totalDur = 0.0f; bool animTried = false; }; @@ -376,6 +390,9 @@ private: if (s_loadedThisFrame >= kLoadsPerFrame) return nullptr; // defer to a later frame s_loadedThisFrame++; Thumb th; th.tried = true; + // Cheap animation probe (header/structure only) so animated items can show a badge without + // decoding all frames; only .gif/.webp can be animations in our decoders. + if (isAnimatableName(absPath)) th.animated = util::IsAnimatedImageFile(absPath.c_str()); int sw = 0, sh = 0; unsigned char* raw = util::LoadRawPixelsFromFile(absPath.c_str(), &sw, &sh); if (raw && sw > 0 && sh > 0) { diff --git a/src/util/texture_loader.cpp b/src/util/texture_loader.cpp index e31217d..dd9d709 100644 --- a/src/util/texture_loader.cpp +++ b/src/util/texture_loader.cpp @@ -467,6 +467,52 @@ bool LoadAnimatedRGBA(const char* path, int maxFrames, AnimFrames& out) } } +// Lightweight animation probe (no full decode): animated WebP via WebPGetFeatures.has_animation, or a +// GIF with >1 image-descriptor block. Walks the GIF block structure only until it finds a 2nd frame. +static bool IsAnimatedData(const unsigned char* data, size_t len) +{ + if (IsWebP(data, len)) { + WebPBitstreamFeatures f; + if (WebPGetFeatures(data, len, &f) == VP8_STATUS_OK) return f.has_animation != 0; + return false; + } + if (len >= 13 && memcmp(data, "GIF8", 4) == 0) { + size_t p = 6; // after "GIF8?a" + unsigned char packed = data[p + 4]; // Logical Screen Descriptor packed byte + p += 7; + if (packed & 0x80) p += (size_t)(2 << (packed & 7)) * 3; // skip global color table + int frames = 0; + while (p < len) { + unsigned char b = data[p++]; + if (b == 0x2C) { // image descriptor = one frame + if (++frames > 1) return true; + if (p + 9 > len) break; + unsigned char ip = data[p + 8]; + p += 9; + if (ip & 0x80) p += (size_t)(2 << (ip & 7)) * 3; // skip local color table + if (p >= len) break; + ++p; // LZW min code size + while (p < len) { unsigned char s = data[p++]; if (!s) break; p += s; } // image sub-blocks + } else if (b == 0x21) { // extension + if (p >= len) break; + ++p; // label + while (p < len) { unsigned char s = data[p++]; if (!s) break; p += s; } // sub-blocks + } else { // trailer (0x3B) or unexpected + break; + } + } + return frames > 1; + } + return false; +} + +bool IsAnimatedImageFile(const char* path) +{ + std::vector file; + if (!ReadFileToBuffer(path, file)) return false; + return IsAnimatedData(file.data(), file.size()); +} + void DestroyTexture(ImTextureID texID) { if (!texID) return; diff --git a/src/util/texture_loader.h b/src/util/texture_loader.h index 826277a..1b0abbd 100644 --- a/src/util/texture_loader.h +++ b/src/util/texture_loader.h @@ -31,6 +31,12 @@ struct AnimFrames { */ bool LoadAnimatedRGBA(const char* path, int maxFrames, AnimFrames& out); +/** + * @brief Cheaply report whether an image file is an animation (multi-frame GIF or animated WebP), + * WITHOUT a full decode — a header/structure probe. False for stills + non-animatable formats. + */ +bool IsAnimatedImageFile(const char* path); + /** * @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)