perf(image-picker): decode hover animations off-thread (no UI hang)

Hovering an animated GIF/WebP previously decoded ALL frames + uploaded every
texture synchronously on the UI thread — a big/long animation froze the UI for
that first hover (stb's GIF decode is monolithic).

Now the decode runs on a detached background worker (bounded to 2 concurrent),
and frames are uploaded to GPU textures a few per UI frame; the still thumbnail
keeps showing until the sequence is ready, then it animates. A shared_ptr job
keeps the worker's result alive if the thumbnail is destroyed mid-decode (e.g.
navigating away), so nothing blocks. Only images the cheap badge-probe already
flagged as animated ever spawn a worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 11:50:27 -05:00
parent e7f2d2e3c7
commit a7c50ff19b

View File

@@ -13,12 +13,15 @@
#pragma once
#include <algorithm>
#include <atomic>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <memory>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
@@ -232,11 +235,12 @@ public:
const Thumb* t = ImGui::IsRectVisible(mn, mx) ? thumbFor(abs) : nullptr;
ImTextureID tex = t ? t->tex : 0;
int tw = t ? t->w : 0, thh = t ? t->h : 0;
// Hovering an animatable image (GIF/WebP) loads its full sequence (once) and plays it.
if (t && hov && isAnimatableName(s_images[i])) {
// Hovering an animated image loads its frames in the BACKGROUND (no UI hang) and plays
// them once ready; the still thumbnail shows meanwhile.
if (t && hov && t->animated) {
Thumb& mt = s_thumbs[abs]; // t is non-null => the entry exists
loadThumbAnim(mt, abs);
if (mt.frames.size() > 1 && mt.totalDur > 0.0f) {
driveThumbAnim(mt, abs);
if (mt.animReady && mt.frames.size() > 1 && mt.totalDur > 0.0f) {
s_animatingThisFrame = true;
double phase = std::fmod(ImGui::GetTime(), (double)mt.totalDur);
double acc = 0.0;
@@ -327,28 +331,70 @@ public:
static bool consumeAnimationActive() { bool v = s_animatingThisFrame; s_animatingThisFrame = false; return v; }
private:
// Background decode job: a detached worker fills `result` off the UI thread and sets `done`. The
// shared_ptr keeps it alive if the owning Thumb is destroyed mid-decode (no blocking on navigate).
struct AnimJob {
std::atomic<bool> done{false};
util::AnimFrames result;
};
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<ImTextureID> frames; // full sequence (loaded on hover)
std::vector<float> delays; int aw = 0, ah = 0; float totalDur = 0.0f; bool animTried = false;
// Async animation load (kicked off on hover): the full frame decode runs on a worker thread,
// then frames are uploaded to textures a few per UI frame — so hovering never blocks the UI.
bool animRequested = false, staged = false, animReady = false;
std::shared_ptr<AnimJob> job;
util::AnimFrames pending; // decoded frames awaiting GPU upload
size_t uploaded = 0;
std::vector<ImTextureID> frames;
std::vector<float> delays; int aw = 0, ah = 0; float totalDur = 0.0f;
};
// Load the full frame sequence for an animated image on first hover (still images stay single-frame).
static void loadThumbAnim(Thumb& th, const std::string& path) {
if (th.animTried) return;
th.animTried = true;
util::AnimFrames af;
if (util::LoadAnimatedRGBA(path.c_str(), kThumbMaxFrames, af) && af.frames.size() > 1) {
th.aw = af.w; th.ah = 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)) {
th.frames.push_back(t);
th.delays.push_back(i < af.delaysSec.size() ? af.delaysSec[i] : 0.1f);
}
// Drive the async animation load for a hovered thumbnail: (1) kick off a background decode once,
// (2) move its result to a staging buffer when done, (3) upload a few frames per UI frame. Never
// blocks — the static thumbnail keeps showing until the sequence is fully uploaded.
static void driveThumbAnim(Thumb& th, const std::string& path) {
if (th.animReady) return;
if (!th.animRequested) {
if (s_animInFlight.load() >= kMaxAnimInFlight) return; // cap workers; retry a later frame
th.animRequested = true;
th.job = std::make_shared<AnimJob>();
s_animInFlight.fetch_add(1);
std::shared_ptr<AnimJob> job = th.job;
std::string p = path;
std::thread([job, p]() {
util::LoadAnimatedRGBA(p.c_str(), kThumbMaxFrames, job->result);
job->done.store(true, std::memory_order_release);
s_animInFlight.fetch_sub(1);
}).detach();
return;
}
if (th.job && th.job->done.load(std::memory_order_acquire)) {
th.pending = std::move(th.job->result);
th.job.reset();
if (th.pending.frames.size() <= 1) { // turned out not to animate
th.animated = false; th.pending = util::AnimFrames{}; th.animReady = true; return;
}
th.staged = true;
}
if (th.staged) {
s_animatingThisFrame = true; // keep redrawing so the upload progresses
int budget = 4;
while (th.uploaded < th.pending.frames.size() && budget-- > 0) {
ImTextureID t = 0;
if (util::CreateRawTexture(th.pending.frames[th.uploaded].data(), th.pending.w, th.pending.h, false, &t)) {
th.frames.push_back(t);
th.delays.push_back(th.uploaded < th.pending.delaysSec.size() ? th.pending.delaysSec[th.uploaded] : 0.1f);
}
th.uploaded++;
}
if (th.uploaded >= th.pending.frames.size()) {
th.aw = th.pending.w; th.ah = th.pending.h;
for (float d : th.delays) th.totalDur += d;
th.pending = util::AnimFrames{};
th.animReady = true;
}
for (float d : th.delays) th.totalDur += d;
}
}
@@ -462,7 +508,9 @@ private:
static constexpr int kThumbPx = 128; // max thumbnail dimension (downscaled)
static constexpr int kLoadsPerFrame = 6; // decode budget per frame (spreads a big folder over frames)
static constexpr int kThumbMaxFrames = 300; // cap frames per hovered animation
static constexpr int kMaxAnimInFlight = 2; // max concurrent background decodes
static inline std::atomic<int> s_animInFlight{0};
static inline bool s_open = false;
static inline bool s_animatingThisFrame = false; // a hover-preview animation is playing
static inline int s_loadedThisFrame = 0;