feat(image-picker): animated-image badge on GIF/WebP thumbnails
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<unsigned char> file;
|
||||
if (!ReadFileToBuffer(path, file)) return false;
|
||||
return IsAnimatedData(file.data(), file.size());
|
||||
}
|
||||
|
||||
void DestroyTexture(ImTextureID texID)
|
||||
{
|
||||
if (!texID) return;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user